Help & Manual

Quick-start guides for each kind of user. Pick your role above.

Public Intake API

Reference for the five endpoints under /api/intake/* that an external system (or the intake page in this app) can call.

Conventions

  • All endpoints accept and return JSON (Content-Type: application/json).
  • Error responses have shape { "error": string }. Messages from public endpoints are deliberately user-safe (no Prisma stack traces or internal paths leaked).
  • Rate-limited endpoints set Retry-After (seconds) on the 429 response.
  • Dates in responses are ISO 8601 strings (e.g. 2026-05-22T05:05:00.000Z); the response also includes a dateFormat (date-fns pattern) so a UI can render them per the global setting.
  • Authentication: no API tokens. Sessions are NextAuth cookies. A client that needs to act as a specific user must complete the Google sign-in flow first; subsequent calls reuse the session cookie. Most intake endpoints don't require auth.

POST /api/intake

Submit a new ticket.

Auth

Optional. If a NextAuth session cookie is present, the submitter is locked to that user's identity — body-supplied createdByName / createdByEmail are overridden with the user's profile, and submittedByUserId is recorded. Without a session, the body fields are accepted as-is and the ticket is treated as anonymous (customer chat will be disabled).

Rate limit

5 submissions per hour per IP (in-process). Exceeding returns 429.

Request body

{
  "deptId": "uuid",         // required — destination department
  "userId": "uuid",         // optional — pre-assign to a user in that department
  "kpiTypeId": "uuid",      // required — must be intake-eligible and assigned to deptId
  "ticketDate": "2026-05-22T05:05:00.000Z",  // required — ISO timestamp
  "description": "Cannot access shared drive",   // required — non-empty
  "createdByName": "Sandy",  // required if not signed in
  "createdByEmail": "sandy@example.com",  // required if not signed in
  "isHolidayWork": false   // optional — count this ticket even if dated to a holiday/weekend
}

Responses

201 Created

{
  "id": "uuid",
  "status": "NEW" | "ONPROGRESS",
  "assignmentEmail": { "status": "sent" | "skipped" | "error", "reason"?: string } | null
}
  • status is ONPROGRESS when userId was provided (pre-assigned). Otherwise NEW.
  • assignmentEmail is null unless a user was assigned and an email attempt was made.

400 Bad Request — one of:

Error messageWhy
Department is requireddeptId missing/invalid
Invalid DepartmentdeptId doesn't exist
KPI Type is requiredkpiTypeId missing
Invalid KPI TypekpiTypeId doesn't exist
This KPI Type is not available for the selected departmentDeptKpi mapping missing
Ticket Date is requiredticketDate missing/unparseable
Uptime KPI is calculated from other KPI tickets and cannot be entered manuallyThe chosen KPI Type is calculated
Description is requireddescription missing
Your name is required / Your email is requiredAnonymous submission with missing fields
Email format is invalidcreatedByEmail doesn't look like an email
Selected user is not assigned to that department or is inactiveuserId invalid for this deptId

429 Too Many Requests{ "error": "Too many submissions. Try again in N seconds." } + Retry-After header.

500 Internal Server Error — friendly message; raw error logged server-side only.

Example

curl -X POST https://your-app.example.com/api/intake \
  -H 'Content-Type: application/json' \
  -d '{
    "deptId": "11111111-1111-1111-1111-111111111111",
    "kpiTypeId": "22222222-2222-2222-2222-222222222222",
    "ticketDate": "2026-05-22T05:05:00.000Z",
    "description": "Cannot access shared drive",
    "createdByName": "Sandy",
    "createdByEmail": "sandy@example.com"
  }'

GET /api/intake/options

Returns the lists needed to render the intake form: countries, departments (filtered to those with intake-eligible KPI types and at least one active user), KPI types grouped by department, and the global date format.

Auth

None. Public.

Response

200 OK

{
  "countries": [
    { "id": "uuid", "name": "Indonesia", "abbreviation": "ID" }
  ],
  "departments": [
    {
      "id": "uuid",
      "name": "Information Technology",
      "abbreviation": "IT",
      "countryId": "uuid",
      "country": { "id": "uuid", "name": "Indonesia", "abbreviation": "ID" },
      "users": [
        { "id": "uuid", "name": "Sandy Rachman", "email": "sandy@example.com" }
      ]
    }
  ],
  "kpiTypesByDept": {
    "<deptId>": [
      {
        "id": "uuid",
        "name": "Ad Hoc",
        "kgId": "uuid",
        "ruleType": "rr",
        "formulaType": "rr",
        "metricSchema": [...],
        "parameters": { "responseSlaMin": 30, "resolutionSlaMin": 480 },
        "subGroup": null,
        "displayOrder": 1,
        "kpiGroup": { "id": "uuid", "name": "Request" }
      }
    ]
  },
  "dateFormat": "dd/MM/yyyy"
}

Calculated KPI types (rule uptime) are excluded — those can't be submitted manually.

500 Internal Server Error — friendly message.


GET /api/intake/lookup?q={query}

Find tickets matching a UUID, exact email, or exact full name. Used by the public "Look up a ticket" widget on the intake page.

Auth

None. Public.

Rate limit

20 lookups per hour per IP.

Query

ParamRequiredNotes
qyesUUID → match by id. Email regex match → match by createdByEmail (case-insensitive). Otherwise → match by createdByName exactly (case-insensitive). Partial name matches are not supported (enumeration deterrent).

Response

200 OK

{
  "dateFormat": "dd/MM/yyyy",
  "tickets": [
    {
      "id": "uuid",
      "status": "NEW" | "ONPROGRESS" | "CLOSED" | "REJECTED" | null,
      "submittedAt": "2026-05-22T05:05:00.000Z",
      "submittedBy": "Sandy",
      "country": "Indonesia",
      "department": "Information Technology",
      "kpiType": "Ad Hoc"
    }
  ]
}

Up to 10 most recent matches, newest first.

429 Too Many Requests{ "error": "Too many lookups. Try again in N seconds." } + Retry-After.

500 Internal Server Error — friendly message.

Example

curl 'https://your-app.example.com/api/intake/lookup?q=sandy@example.com'

GET /api/intake/status/{id}

Fetch the public-safe status of one ticket. Includes the chat history and a canMessage flag computed against the current viewer's session.

Auth

Optional. The session is read to decide canMessage. Without a session, the response still includes all fields except chat is read-only.

Response

200 OK

{
  "id": "uuid",
  "status": "NEW" | "ONPROGRESS" | "CLOSED" | "REJECTED",  // 'NEW' falls back for self-logged tickets
  "submittedAt": "2026-05-22T05:05:00.000Z",
  "assignedAt": "2026-05-22T05:10:00.000Z" | null,
  "firstAgentReplyAt": "2026-05-22T06:11:00.000Z" | null,
  "closedAt": "2026-05-22T08:00:00.000Z" | null,
  "firstResponseTimeMin": 5 | null,
  "resolutionTimeMin": 120 | null,
  "description": "Cannot access shared drive",
  "rejectionReason": "Duplicate request" | null,
  "submittedBy": "Sandy" | null,
  "submittedByEmail": "sandy@example.com" | null,
  "country": "Indonesia" | null,
  "department": "Information Technology" | null,
  "kpiType": "Ad Hoc" | null,
  "assignedTo": "Sandy Rachman" | null,
  "canMessage": false,
  "messageDisabledReason": "Sign in to reply to this ticket." | null,
  "dateFormat": "dd/MM/yyyy",
  "messages": [
    {
      "id": "uuid",
      "authorType": "AGENT" | "CUSTOMER",
      "authorName": "Sandy Rachman" | null,
      "authorEmail": "sandy@example.com" | null,
      "body": "Licence have been renew",
      "createdAt": "2026-05-22T06:11:00.000Z"
    }
  ]
}

canMessage is true only when all of:

  • The ticket has a status (i.e. it's an intake ticket, not a self-logged one).
  • ticket.submittedByUserId is set (the submitter was signed in at submission).
  • The current viewer's user id equals ticket.submittedByUserId.

When canMessage is false, messageDisabledReason explains why ("This ticket was submitted without sign-in…" / "Sign in to reply…" / "This ticket can only be replied to by the original submitter.").

400 Bad Request{ "error": "Ticket id is required" }.

404 Not Found{ "error": "Ticket not found" }.

500 Internal Server Error — friendly message.

Example

curl 'https://your-app.example.com/api/intake/status/2a58b5d1-668b-447d-8fc3-f52149d4fc71'

POST /api/intake/status/{id}/messages

Customer-side chat: append a message to the ticket's discussion.

Auth

Required. The viewer must be signed in and their user id must equal ticket.submittedByUserId. Tickets submitted anonymously (no submittedByUserId) can never receive customer messages; the agent must follow up via email.

Rate limit

20 messages per hour per IP per ticket.

Request body

{
  "body": "Thanks — that resolved it."   // required, max 2000 characters
}

The server ignores any authorName / authorEmail in the body — identity is taken from the verified session.

Responses

201 Created

{
  "message": {
    "id": "uuid",
    "authorType": "CUSTOMER",
    "authorName": "Sandy Rachman",
    "authorEmail": "sandy@example.com",
    "body": "Thanks — that resolved it.",
    "createdAt": "2026-05-22T08:00:00.000Z"
  }
}

400 Bad Request — one of:

ErrorWhy
Ticket id is requiredURL missing the id segment
Message is requiredBody empty
Message must be 2000 characters or fewerLength limit
Discussion is available only for intake ticketsTicket has no status (self-logged)

401 UnauthorizedSign in to reply to this ticket.

403 Forbidden — one of:

ErrorWhy
This ticket was submitted without sign-in. Customer replies are not available — the agent will follow up via email.submittedByUserId is null on the ticket
Only the original submitter can reply to this ticket.Session user differs from submittedByUserId

404 Not Found — ticket id doesn't exist.

429 Too Many RequestsRetry-After header.

500 Internal Server Error — friendly message.

Example

# Assumes a session cookie has already been established via NextAuth.
curl -X POST 'https://your-app.example.com/api/intake/status/2a58b5d1.../messages' \
  -H 'Content-Type: application/json' \
  -b 'next-auth.session-token=...' \
  -d '{ "body": "Thanks — that resolved it." }'

Typical integration flow

  1. Bootstrap: GET /api/intake/options → cache the country / department / KPI Type lists. Refresh occasionally (admins change them rarely).
  2. Submit: POST /api/intake with the picked IDs + customer details → store the returned ticket id.
  3. (Optional) Sign-in flow: if you want the customer to chat, send them through your NextAuth Google sign-in before they reach the status page; the session cookie persists.
  4. Poll status: GET /api/intake/status/{id} periodically (or use a refresh button). Observe status transitions, messages, and the response/resolution metrics.
  5. Send chat: POST /api/intake/status/{id}/messages while canMessage is true.
  6. Find later: if the customer lost the URL, GET /api/intake/lookup?q=....

Stability notes

  • Endpoint shapes follow the source under src/app/api/intake/. Breaking changes will be called out in repo commits but no versioning header is in place — pin to a known commit if you need a stable contract.
  • friendlyErrorMessage masks server stack traces in 500 responses for these public endpoints; expect generic prose, not structured details.
  • Schema fields beyond what's documented here may appear in responses (e.g. additional metadata on tickets[].messages); consumers should ignore unknown fields.

Pick a role: CustomerSubmit a ticket and chat with the agent. · AgentHandle tickets, self-log activity, close tickets. · ManagementRead dashboards and monthly reports. · AdminManage users, KPIs, settings, and audit history. · DeveloperPublic intake API reference for external integrations.