Skip to content

Latest commit

 

History

History
648 lines (441 loc) · 25.5 KB

API.md

File metadata and controls

648 lines (441 loc) · 25.5 KB

SchemeAI API Reference

This reference covers every route handler under src/app/api. All endpoints send and receive JSON. For how the endpoints fit together, see FLOWS.md.

Conventions

  • Auth. Sessions use an httpOnly cookie named schemeai_session (SameSite Lax, Secure in production, 14-day expiry). It is set by register and login and checked with getSessionUser() in src/lib/auth.ts. "Session" in the tables below means the route returns 401 without a valid session. "Admin" means the route calls requireAdmin(), which accepts only a session whose user has role = "admin".
  • Errors. Errors are returned as { "error": string }. Unexpected exceptions return 500 with the exception message. Most routes catch them; saved, tracker and admin stats do not use try/catch, so their exceptions surface as the framework's default 500.
  • Validation. Request bodies are validated with zod (v4) where noted.
  • Serialized scheme. Many responses contain serializeScheme() output from src/lib/serialize.ts, in one of two shapes, described below.

SchemeSummary (serializeScheme(s))

{
  id, slug, name, nameHi, shortDescription, shortDescriptionHi,
  category, ministry, department, jurisdiction, state, beneficiaryType,
  benefits, benefitAmount, applicationUrl, infoUrl,
  verificationStatus,   // "REFERENCE" | "LIVE_CHECKED" | "STALE"
  extractionMethod,     // "SEED_REFERENCE" | "DIRECT_FETCH" | "TINYFISH_LIVE"
  confidence,           // "HIGH" | "MEDIUM"
  dataCompiledAt, lastLiveCheckedAt,
  ruleCount, sourceName, sourceDomain
}

SchemeDetail (serializeScheme(s, true))

SchemeSummary plus:

{
  description, applicationWindow, applicationDeadline,
  documents: string[], process: string[], keywords,
  rules: [{ attribute, operator, value /* JSON string */, required, label, labelHi, ruleGroup }],
  evidence: [{ sourceUrl, excerpt, evidenceType, note, retrievedAt }], // [] if evidence not loaded
  sourceMeta: { name, domain, department, lastCheckedAt, lastCheckStatus, lastCheckTitle } | null
}

SchemeMatchResult (evaluateScheme() in src/lib/eligibility.ts)

{
  status: "LIKELY_ELIGIBLE" | "POSSIBLY_ELIGIBLE" | "NOT_ELIGIBLE" | "UNABLE_TO_DETERMINE",
  relevanceScore: number,          // 0–100
  relevanceBand: "HIGH_RELEVANCE" | "POTENTIAL_MATCH" | "EXPLORE",
  reasons: string[], reasonsHi: string[],
  toVerify: string[], toVerifyHi: string[],
  rules: [{ label, labelHi, status: "MET"|"FAILED"|"UNKNOWN", explanation, explanationHi,
            required, ruleGroup, userValue, requirement }],
  infoCompleteness: number         // 0–1
}

UserProfile

{
  age?, state?, district?, gender? /* male|female|other */, educationLevel?, employmentStatus?,
  occupation?, annualIncome?, residence? /* rural|urban */, socialCategory?,
  isFarmer?, hasDisability?, disabilityPercent?, isMinority?, isWidow?
}

Every field is optional and nullable.

Endpoint index

Method Path Auth Purpose
GET /api/health none Database and TinyFish health
GET /api/schemes none List, search, filter, paginate
GET /api/schemes/[id] none Scheme detail by id or slug, optional match
POST /api/schemes/match none Run the eligibility engine over all schemes
POST /api/schemes/compare none Compare 1–3 schemes
POST /api/assistant none Grounded Q&A over scheme records
GET /api/sources none Official source registry
POST /api/sources/verify none with schemeId, otherwise admin Live HTTP check of one source, plus a TinyFish refresh of one scheme when schemeId is sent
POST /api/discovery admin Live discovery run over up to 5 sources (direct check + TinyFish extraction)
GET /api/discovery none Last 30 fetch jobs
POST /api/auth/register none Create account and session
POST /api/auth/login none Start session
POST /api/auth/logout none End session
GET /api/auth/me optional Current user, saved IDs, tracker
GET / POST / DELETE /api/saved session Saved schemes
GET / PUT /api/tracker session Self-reported application status
GET /api/admin/stats admin System metrics
POST /api/setup setup secret Seed sources, schemes and demo admin

Health

GET /api/health

Source: src/app/api/health/route.ts

Auth: none. Params: none.

200

{ "status": "ok", "database": "connected", "schemes": 0, "sources": 0,
  "tinyfish": { "status": "NOT_CONFIGURED", "message": "..." }, "time": "ISO-8601" }

503 if either count query throws:

{ "status": "degraded", "database": "error", "error": "...", "time": "ISO-8601" }

Calls: db.scheme.count, db.governmentSource.count, tinyFishStatus().


Schemes

GET /api/schemes

Source: src/app/api/schemes/route.ts

Auth: none.

Query Default Notes
q "" Trimmed and lowercased. Matched case-insensitively (contains with mode: "insensitive", OR) against name, nameHi, shortDescription, description, keywords, ministry, beneficiaryType, benefits.
category — Exact match.
state — Exact match on state.
level — national sets state = null. state sets state != null. Either one overrides the state filter.
sort relevance name (asc), category (asc), recent (dataCompiledAt desc). Anything else sorts by verificationStatus asc (so LIVE_CHECKED comes before REFERENCE), then name asc.
page 1 At least 1.
perPage 12 Clamped to 1–48.

200

{ "total": 0, "page": 1, "perPage": 12, "results": [ /* SchemeSummary */ ] }

500 { "error": "..." }

Calls: db.scheme.count, db.scheme.findMany (include eligibilityRules, source), serializeScheme.

Note: the relevance sort is not a text-relevance ranking. It is a string sort on verificationStatus that lists live-checked schemes first, then sorts by name.

GET /api/schemes/[id]

Source: src/app/api/schemes/[id]/route.ts

Auth: none.

Param Notes
id (path) Matches Scheme.id or Scheme.slug.
profile (query, optional) URL-encoded JSON UserProfile. The route runs decodeURIComponent and then JSON.parse. It is not schema-validated, and a parse error is silently ignored.

200

{ "scheme": { /* SchemeDetail */ }, "match": { /* SchemeMatchResult */ } }

match is omitted when there is no profile or when it fails to parse.

404 { "error": "Scheme not found" } · 500

Calls: db.scheme.findFirst (include eligibilityRules, evidence, source), evaluateScheme, serializeScheme(s, true).

POST /api/schemes/match

Source: src/app/api/schemes/match/route.ts

Auth: none.

Body: either { "profile": UserProfile } or a bare UserProfile. The route reads body.profile ?? body. The strict zod schema applies these limits:

Field Constraint
age int, 0–120
state, district string, max 80
gender male | female | other
educationLevel, employmentStatus string, max 40
occupation string, max 60
annualIncome int, 0–100,000,000
residence rural | urban
socialCategory string, max 20
isFarmer, hasDisability, isMinority, isWidow boolean
disabilityPercent int, 0–100

Every field is .nullable().optional(). Unknown keys are stripped.

200

{
  "results": [ { "scheme": { /* SchemeSummary */ }, "match": { /* SchemeMatchResult */ } } ],
  "summary": { "total": 0, "likely": 0, "possibly": 0, "notEligible": 0, "unable": 0 }
}

results covers every scheme, sorted by match.relevanceScore descending.

400 { "error": "Invalid profile", "details": [ /* up to 5 zod issues */ ] } · 500

Calls: db.scheme.findMany (include eligibilityRules, source), evaluateScheme, serializeScheme.

POST /api/schemes/compare

Source: src/app/api/schemes/compare/route.ts

Auth: none.

Body

{ "ids": ["scheme-id"], "profile": { } }
  • ids: 1–3 strings. These must be Scheme.id values; slugs are not resolved here. Seeded schemes use the slug as their id, so in practice both work.
  • profile: optional record<string, unknown>, cast to UserProfile without per-field validation.

200

{ "results": [ { "scheme": { /* SchemeDetail */ }, "match": { /* SchemeMatchResult */ } } ] }
  • Results come back in the order of ids.
  • IDs that are not found are dropped.
  • match is null when no profile is sent.

400 { "error": "Send 1–3 scheme ids" } · 500

Calls: db.scheme.findMany (include eligibilityRules, evidence, source), serializeScheme(s, true), evaluateScheme.


Assistant

POST /api/assistant

Source: src/app/api/assistant/route.ts

Auth: none.

Body

Field Type Notes
question string, 2–600 required
lang en | hi default en
schemeId string optional. Gives the matching scheme +100 in retrieval scoring.
profile record<string, unknown> optional. Included in the prompt as raw JSON.

200

{
  "answer": "string",
  "mode": "AI_GROUNDED",
  "cited": [ { "id": "", "name": "", "slug": "", "infoUrl": "" } ],
  "disclaimer": "string (en or hi)"
}
  • mode is AI_GROUNDED or DETERMINISTIC.
  • cited holds the retrieved schemes, up to 5.

400 { "error": "Invalid question" } · 500

Calls:

  • db.scheme.findMany (loads all schemes, with eligibilityRules and source)
  • serializeScheme(s, true)
  • z-ai-web-dev-sdk: ZAI.create() then zai.chat.completions.create({ messages: [system, user] }), with no explicit model or parameters
  • deterministicAnswer() as the fallback when the SDK throws or returns empty content

The prompt construction and fallback behaviour are described in FLOWS.md section 8.


Sources and live verification

GET /api/sources

Source: src/app/api/sources/route.ts

Auth: none. Params: none.

200

{
  "sources": [ {
    "id": "", "name": "", "domain": "", "jurisdiction": "", "department": "",
    "accessMethod": "DIRECT_FETCH", "accessStatus": "UNKNOWN",
    "automationSupported": false, "reliability": "HIGH", "notes": null,
    "lastCheckedAt": null, "lastCheckStatus": null, "lastCheckTitle": null,
    "lastSuccessAt": null, "lastFailureAt": null,
    "schemeCount": 0, "jobCount": 0
  } ],
  "tinyfish": { "status": "NOT_CONFIGURED", "message": "" }
}
  • accessMethod is DIRECT_FETCH, TINYFISH_AUTOMATION or OPEN_DATA.
  • accessStatus is UNKNOWN, PERMITTED or RESTRICTED.
  • Sources are ordered by jurisdiction desc, then name asc.

500

Calls: db.governmentSource.findMany (with _count of schemes and fetchJobs), tinyFishStatus().

POST /api/sources/verify

Source: src/app/api/sources/verify/route.ts. Exports maxDuration = 180.

Body

Field Type Notes
sourceId string required
schemeId string optional. Also refreshes this scheme from its official page with TinyFish.

Auth

  • With schemeId: none. Any visitor can run a scheme refresh; this is what the scheme page's Verify button sends.
  • Without schemeId: admin. Otherwise 401 { "ok": false, "error": "Admin sign-in required", "message": "..." }.

The scheme must exist (404 otherwise) and belong to sourceId (400 otherwise), so the public path cannot be used to start arbitrary source checks.

What runs (the two steps run in parallel):

  1. verifySourceAndRecord(sourceId) in src/lib/live-check.ts:
    1. Creates a FetchJob with type SOURCE_VERIFY and mode DIRECT_FETCH.
    2. Calls performLiveCheck(domain): a HEAD request to https://<domain>, followed by a GET if HEAD returns 2xx, 403 or 405. Each request has a 12 s timeout, and the page <title> is extracted from the GET.
    3. Updates the job and the GovernmentSource fields lastChecked*, lastSuccessAt / lastFailureAt and accessStatus (PERMITTED or RESTRICTED).
    4. On success, sets verificationStatus = "LIVE_CHECKED" for every scheme linked to the source.
  2. Only when schemeId is sent and TinyFish is READY, refreshSchemeWithTinyFish(schemeId):
    1. If the scheme has a SUCCEEDED SCHEME_REFRESH job completed in the last 6 hours, that job's stored result is returned and no new run starts.
    2. Otherwise it creates a FetchJob (SCHEME_REFRESH, mode TINYFISH, with schemeId) and calls tinyFishExtractSchemePage(scheme.infoUrl, scheme.name) (see TINYFISH.md).
    3. Stores the compacted run result in FetchJob.resultJson.
    4. On success, replaces the scheme's LIVE_FETCH SourceEvidence row with an excerpt built from the extracted deadline, application window, benefit, eligibility, documents and page-updated date (the note carries the TinyFish run ID), and sets the scheme to LIVE_CHECKED. Curated scheme fields are not changed.

200: returned whether the checks pass or fail.

{
  "ok": true,
  "mode": "TINYFISH+DIRECT",
  "domain": "example.gov.in",
  "httpStatus": 200, "title": "Page title", "durationMs": 812, "error": null,
  "tinyfish": {
    "ok": true, "cached": false, "runId": "", "status": "COMPLETED", "error": null,
    "result": {
      "scheme_name": "", "benefit_amount": null, "eligibility": [], "documents_required": [],
      "application_window": null, "deadline": null, "application_url": null,
      "last_updated": null, "blocked": false
    }
  },
  "message": "Live check succeeded: <name> responded with HTTP 200. TinyFish extracted current details from the official scheme page."
}
  • mode is TINYFISH+DIRECT when a TinyFish refresh ran or was served from cache, otherwise DIRECT_FETCH.
  • ok is true when either the direct check or the TinyFish refresh succeeded.
  • tinyfish is null when no schemeId was sent or TinyFish is NOT_CONFIGURED. A cached result has cached: true and cachedAt. A failed run has ok: false with an error and a status such as BLOCKED, TIMEOUT, FAILED or HTTP_401.

400 { "error": "sourceId required" }

500 { "error": "Source not found" } or { "error": "Scheme not found" }: unknown IDs throw inside the library functions and come back as 500, not 404.

POST /api/discovery

Source: src/app/api/discovery/route.ts. Exports maxDuration = 300.

Auth: admin. Otherwise 401 { "error": "Admin sign-in required to run live discovery." }.

Body (optional): { "sourceIds": ["id", "..."], "useTinyFish": true }

  • sourceIds takes at most 5 entries. Without it (or with an invalid body), the 5 sources with the oldest lastCheckedAt are checked.
  • useTinyFish defaults to true; send false for direct checks only. It has no effect while TinyFish is NOT_CONFIGURED.

For each source the route creates a FetchJob (type LIVE_DISCOVERY, mode TINYFISH or DIRECT_FETCH) and runs, in parallel:

  • performLiveCheck(domain), always;
  • tinyFishExtractSourcePage("https://<domain>"), when TinyFish is used.

When TinyFish is used, at most 2 sources are processed at a time (otherwise up to 5). A job is SUCCEEDED when either check succeeded. Its resultJson stores httpStatus, title, durationMs, tinyFishUsed and the compacted TinyFish result (ok, runId, status, steps, durationMs, error, result). The source and its linked schemes are updated as for verify; when only TinyFish succeeded, lastCheckStatus is TINYFISH_OK.

200

{
  "tinyfish": { "status": "READY", "message": "" },
  "checked": 1,
  "jobs": [ {
    "jobId": "", "source": "<source name>", "mode": "TINYFISH+DIRECT",
    "status": "SUCCEEDED", "httpStatus": 200, "title": "", "durationMs": 0, "error": null,
    "tinyfish": {
      "ok": true, "runId": "", "status": "COMPLETED", "error": null,
      "schemeNames": [], "announcements": [ { "text": "", "date": null } ],
      "deadlines": [ { "label": "", "date": "" } ]
    }
  } ],
  "note": "string"
}
  • Job mode is TINYFISH+DIRECT or DIRECT_FETCH; tinyfish is null for direct-only jobs.
  • Job status is SUCCEEDED or FAILED. error joins the direct and TinyFish errors (Direct: ... | TinyFish: ...).
  • title is the direct-check page title, or TinyFish's page_title when the direct check found none.
  • durationMs is the longer of the two checks.

500

Calls: requireAdmin, tinyFishStatus, performLiveCheck, tinyFishExtractSourcePage, compactTinyFishResult, and Prisma updates on FetchJob, GovernmentSource and Scheme.

GET /api/discovery

Auth: none. Params: none.

200

{ "jobs": [ { "id": "", "type": "", "mode": "", "status": "", "source": null,
              "targetUrl": "", "error": null, "startedAt": "", "completedAt": null } ] }

Returns the 30 most recent jobs by startedAt desc. No client component calls this endpoint.

500

Calls: db.fetchJob.findMany (include source.name, source.domain).


Auth

Session helpers are in src/lib/auth.ts:

  • hashPassword and verifyPassword use scrypt with a 16-byte salt and a 64-byte key, stored as salt:hash. Verification uses timingSafeEqual.
  • createSession stores the sha256 of a random 32-byte token and sets the cookie.
  • destroySession deletes the session row and the cookie.
  • getSessionUser looks up the session and lazily deletes expired ones.
  • requireAdmin returns the session user only when its role is admin. It guards POST /api/discovery, POST /api/sources/verify without schemeId, and GET /api/admin/stats.

POST /api/auth/register

Source: src/app/api/auth/register/route.ts

Body: { "email": "string (email, max 200)", "password": "string (8–100)", "name": "string (max 100, optional)" }

The email is lowercased before lookup and storage.

200 (also sets schemeai_session):

{ "user": { "id": "", "email": "", "name": null, "role": "user" } }

400 { "error": "Enter a valid email and a password of at least 8 characters." } 409 { "error": "An account with this email already exists." } 500

Calls: db.user.findUnique, hashPassword, db.user.create, createSession, db.auditLog.create (action USER_REGISTER).

POST /api/auth/login

Source: src/app/api/auth/login/route.ts

Body: { "email": "string (email, max 200)", "password": "string (1–100)" }

200 (sets schemeai_session): { "user": { "id", "email", "name", "role" } }

400 { "error": "Enter your email and password." } 401 { "error": "Incorrect email or password." } 500

Calls: db.user.findUnique, verifyPassword, createSession.

There is no rate limiting or lockout, and logins are not written to the audit log.

POST /api/auth/logout

Source: src/app/api/auth/logout/route.ts

Body: none. 200 { "ok": true } (always).

Calls: destroySession().

GET /api/auth/me

Source: src/app/api/auth/me/route.ts

Auth: optional. This route never returns 401.

200 without a session: { "user": null }

200 with a session:

{
  "user": { "id": "", "email": "", "name": null, "role": "user" },
  "savedIds": ["scheme-id"],
  "tracker": [ { "schemeId": "", "status": "INTERESTED", "updatedAt": "" } ],
  "profile": null
}

profile is the UserProfile row or null. No endpoint writes that table, so it is always null in practice.

Calls: getSessionUser, db.savedScheme.findMany, db.applicationTracker.findMany, db.userProfile.findUnique.


Saved schemes

Source: src/app/api/saved/route.ts. Auth: session for all methods. Without a session, every method returns 401 { "error": "Sign in required" }.

GET /api/saved

200 { "saved": [ { "schemeId": "", "scheme": { /* SchemeSummary */ } } ] }, newest first.

Calls: db.savedScheme.findMany (include scheme with rules and source), serializeScheme (dynamically imported). No client component calls this endpoint.

POST /api/saved

Body: { "schemeId": "string" }

200 { "ok": true, "schemeId": "" } (idempotent upsert)

400 { "error": "schemeId required" } · 404 { "error": "Scheme not found" }

Calls: db.scheme.findUnique, db.savedScheme.upsert.

DELETE /api/saved

Body: { "schemeId": "string" }. The ID goes in the JSON request body, not the URL.

200 { "ok": true } (also when nothing was deleted) · 400 { "error": "schemeId required" }

Calls: db.savedScheme.deleteMany.


Application tracker

Source: src/app/api/tracker/route.ts. Auth: session. Without one: 401 { "error": "Sign in required" }.

GET /api/tracker

200

{ "tracker": [ { "schemeId": "", "status": "", "updatedAt": "", "scheme": { "id": "", "name": "", "slug": "" } } ] }

Entries are ordered by updatedAt desc. No client component calls this endpoint; the client gets tracker data from /api/auth/me.

PUT /api/tracker

Body: { "schemeId": "string", "status": "INTERESTED" | "PREPARING_DOCUMENTS" | "APPLIED_EXTERNALLY" | "UNDER_REVIEW" | "COMPLETED" }

200 { "ok": true }

400 { "error": "Invalid tracker update" } · 404 { "error": "Scheme not found" }

Calls: db.scheme.findUnique, db.applicationTracker.upsert.


Admin

GET /api/admin/stats

Source: src/app/api/admin/stats/route.ts

Auth: admin. Without a session the route returns 401; a signed-in user without the admin role gets 403.

200

{
  "isAdmin": true,
  "stats": { "totalSchemes": 0, "totalSources": 0, "liveChecked": 0,
             "totalJobs": 0, "failedJobs": 0, "totalUsers": 0 },
  "tinyfish": { "status": "", "message": "" },
  "sources": [ { "id": "", "name": "", "domain": "", "jurisdiction": "", "accessStatus": "",
                 "lastCheckedAt": null, "lastCheckStatus": null, "lastCheckTitle": null, "schemeCount": 0 } ],
  "recentJobs": [ { "id": "", "type": "", "mode": "", "status": "", "source": null,
                    "startedAt": "", "completedAt": null, "error": null } ]
}

401 { "error": "Sign in required" } · 403 { "error": "Admin access required" }

Calls: getSessionUser, requireAdmin, db.scheme.count (x2), db.governmentSource.count, db.fetchJob.count (x2), db.user.count, db.fetchJob.findMany (take 10), db.governmentSource.findMany, tinyFishStatus().


Setup

POST /api/setup

Source: src/app/api/setup/route.ts

Auth: shared secret. The request needs both:

  • the query parameter setup=1
  • the header x-setup-secret: <SETUP_SECRET>

Body: none.

200

{ "ok": true, "created": 0, "updated": 0,
  "counts": { "sources": 0, "schemes": 0, "rules": 0, "admin": "admin@schemeai.in" } }

401 { "error": "Unauthorized." } 500 { "error": "SETUP_SECRET is not set. Add it in Vercel env vars first." }, or any seeding exception.

Calls:

  • ALL_SEED_SOURCES and ALL_SEED_SCHEMES from src/lib/schemes
  • governmentSource.upsert (by domain)
  • scheme.update or scheme.create (by slug, with id = slug on create)
  • schemeEligibilityRule deleteMany then create
  • sourceEvidence deleteMany (GUIDELINE_EXCERPT only) then create
  • user.create for the demo admin admin@schemeai.in with the fixed password Admin@1234, only if that user does not already exist

The route is idempotent. The CLI equivalents are scripts/seed.ts (npm run db:seed) and scripts/create-admin.ts (npm run db:seed:admin).


Environment variables referenced by the API

Variable Used in Purpose
POSTGRES_PRISMA_URL / DATABASE_URL db.ts Database connection. The first takes precedence.
SETUP_SECRET /api/setup Gates seeding.
TINYFISH_API_KEY tinyfish.ts Required for TinyFish READY. Sent as the X-API-Key header.
TINYFISH_API_URL tinyfish.ts Optional. Default https://agent.tinyfish.ai.
NODE_ENV auth.ts production sets the Secure flag on the cookie.

The assistant's LLM credentials are not read by route code. z-ai-web-dev-sdk's ZAI.create() resolves them itself.