DEV Community

Nazmul Ahasan
Nazmul Ahasan

Posted on Originally published at nazmul-ahasan.pro.bd

Forward Deployed Engineer challenge: I built a company ERP portal in 2 days with AI

Forward Deployed Engineer challenge: I built a company ERP portal in 2 days with AI

A company posted a Forward Deployed Engineer (FDE) job ad with a take-home brief: build an internal operations portal in three working days. I took the challenge and shipped it alone in two, with AI doing much of the typing. The company took the ad down and adopted the portal. Today about 700 employees use it.

I'm Nazmul Ahasan. I run Platform Engineering as a Senior Engineering Manager, and I write about this kind of work on my site, Nazmul Ahasan. To be clear up front: I didn't hold the FDE title. I took the FDE challenge. This post covers what I built, how I'd use AI on a build like this, what you must check by hand, and the part I think matters most: the approval engine.

What the Forward Deployed Engineer challenge asked for

The brief described an internal ops/ERP portal. I called it OpsFlow (the full write-up is in the OpsFlow case study). The scope I delivered:

  • Multi-step approvals, driven by config rather than hard-coded flows
  • Leave requests: employee → line manager → HR → automatic email → leave balance deducted
  • Salary certificates
  • Payroll
  • Advance salary

The stack was boring on purpose: Next.js, Prisma and PostgreSQL, running on AWS EC2. Boring is what you want when you have two days. Every tool in that list is one I could debug without a search engine, which matters when the AI gets something confidently wrong.

That's the real point of an FDE-style brief, I think. It isn't asking whether you can write code. It's asking whether you can sit with a messy business problem, pick a shape for it fast, and put something real in front of users. AI changes how fast you can write code. It doesn't change who is responsible for the result.

How I'd use AI on a build like this

I used AI coding assistants throughout. The way I think about them: a fast, tireless junior engineer with zero context about the business and a habit of sounding certain. That framing decides everything else.

Where AI earns its place:

  • Scaffolding. Next.js pages, forms, table views, Prisma model boilerplate, API route shells. High volume, low risk, and where most of two days would otherwise go.
  • Repetitive variations. Once one approval flow works, the next ones are mostly "same pattern, different fields and steps". AI is very good at following a pattern you've already set.
  • Rubber-ducking the design. Talking through the data model before writing it surfaces awkward cases early, like what happens when the approver is also the requester.

The prompting workflow I'd insist on:

  1. Design first, by hand. Write the data model and the workflow config format yourself. Everything else depends on them, and they're small enough to think through properly.
  2. Give the model the contract. Paste the schema and types into the prompt and ask for code that fits them, instead of asking for "an approval system" and getting a generic one.
  3. Small, reviewable chunks. One route, one component, one function at a time. If you can't review a diff in a couple of minutes, the request was too big.
  4. Never accept code you can't explain. If the output uses something you don't understand, learn it or rewrite it.

The trap with AI-assisted speed is that you can generate code faster than you can understand it. The fix isn't to slow the AI down. It's to keep the parts that need real thought small, and keep them in your own hands.

The parts you must check by hand

Some code is cheap to get wrong. A misaligned table is a bug report. A wrong payroll calculation breaks trust with hundreds of people. Split the codebase along that line. This is the checklist I'd hold any AI-assisted internal tool to:

Auth and RBAC. Every server action and API route that changes state must check who the user is and what role they hold, on the server. AI-generated code tends to put the check in the UI (hide the button) and forget the endpoint. Read every mutation path for that.

Payroll and salary math. Money logic gets read line by line: rounding, deductions, advance salary recovery, partial months. Check worked examples against what HR already calculates, not against what the model says is correct.

Migrations. Prisma makes schema changes easy, which is exactly why they need reading. Every generated migration gets a human look before it touches a real database, especially anything that drops or renames a column.

Approval edge cases. Double clicks. Two approvers acting at once. An approver acting on a step that has already moved on. A requester approving their own request. A rejected request being approved later. These are the bugs that make people stop trusting a system, and AI-generated happy-path code misses them.

Testing. The highest-value tests are on the state-transition function, because every workflow goes through it. After that, walk each flow end to end as each role.

The approval engine design

This is the part I'd defend in a design review. Leave, salary certificates and advance salary all have the same shape: someone asks, a sequence of people approve or reject, and when it's done, something happens. So I built one engine and made each flow a piece of config.

The model has four parts:

  • ApprovalWorkflow: a named workflow, such as "leave".
  • ApprovalStep: an ordered step in a workflow, with the role that's allowed to act on it.
  • ApprovalRequest: one instance, such as one employee's leave request, with its current step and status.
  • ApprovalAction: an append-only record of who did what, and when.

Prisma schema sketch

This is a sanitized sketch, not the production schema.

enum RequestStatus {
  PENDING
  APPROVED
  REJECTED
  CANCELLED
}

enum ActionType {
  APPROVE
  REJECT
}

model ApprovalWorkflow {
  id        String            @id @default(cuid())
  key       String            @unique // e.g. "leave"
  name      String
  steps     ApprovalStep[]
  requests  ApprovalRequest[]
  createdAt DateTime          @default(now())
}

model ApprovalStep {
  id         String           @id @default(cuid())
  workflowId String
  workflow   ApprovalWorkflow @relation(fields: [workflowId], references: [id])
  order      Int
  key        String           // e.g. "line_manager"
  role       String           // role allowed to act on this step

  @@unique([workflowId, order])
}

model ApprovalRequest {
  id          String           @id @default(cuid())
  workflowId  String
  workflow    ApprovalWorkflow @relation(fields: [workflowId], references: [id])
  requesterId String
  currentStep Int              @default(0)
  status      RequestStatus    @default(PENDING)
  payload     Json             // flow-specific data, e.g. leave dates
  version     Int              @default(0) // optimistic concurrency
  actions     ApprovalAction[]
  createdAt   DateTime         @default(now())
  updatedAt   DateTime         @updatedAt
}

model ApprovalAction {
  id             String          @id @default(cuid())
  requestId      String
  request        ApprovalRequest @relation(fields: [requestId], references: [id])
  stepOrder      Int
  actorId        String
  type           ActionType
  comment        String?
  idempotencyKey String          @unique
  createdAt      DateTime        @default(now())

  @@unique([requestId, stepOrder]) // one decision per step
}
Enter fullscreen mode Exit fullscreen mode

Two constraints do a lot of quiet work here. idempotencyKey is unique, so a retried request can't record a second action. And @@unique([requestId, stepOrder]) means a step can only be decided once, whatever the application code does.

A config-driven workflow definition

The steps live in the database so they're inspectable, but the behaviour lives in typed config. Adding a workflow means adding a definition, not writing a new state machine.

type Role = "EMPLOYEE" | "LINE_MANAGER" | "HR" | "FINANCE";

interface StepDef {
  key: string;
  role: Role;
}

interface CompletionContext<P> {
  tx: TransactionClient; // Prisma transaction client
  requestId: string;
  requesterId: string;
  payload: P;
  enqueueEmail: (msg: { to: string; template: string; data: unknown }) => Promise<void>;
}

interface WorkflowDef<P> {
  key: string;
  name: string;
  steps: StepDef[];
  validate: (payload: unknown) => P;
  onComplete?: (ctx: CompletionContext<P>) => Promise<void>;
}

interface LeavePayload {
  leaveType: string;
  startDate: string;
  endDate: string;
  days: number;
}

export const leaveWorkflow: WorkflowDef<LeavePayload> = {
  key: "leave",
  name: "Leave request",
  steps: [
    { key: "line_manager", role: "LINE_MANAGER" },
    { key: "hr", role: "HR" },
  ],
  validate: (input) => parseLeavePayload(input), // throws on bad input
  async onComplete({ tx, requesterId, payload, enqueueEmail }) {
    await tx.leaveBalance.update({
      where: {
        employeeId_leaveType: { employeeId: requesterId, leaveType: payload.leaveType },
      },
      data: { remaining: { decrement: payload.days } },
    });
    await enqueueEmail({
      to: requesterId,
      template: "leave-approved",
      data: { startDate: payload.startDate, endDate: payload.endDate },
    });
  },
};
Enter fullscreen mode Exit fullscreen mode

The employee is the implicit first step: creating the request is their action. After that it's line manager, then HR, then the completion hook sends the email and deducts the balance.

This is the way I'd design it: the balance deduction runs inside the same transaction as the final approval, so if the deduction fails, the approval doesn't happen. A request marked "approved" with an untouched balance is worse than a clear error. And the email should be queued rather than sent inline, because you can't roll back an email. That's the design that holds up.

The state-transition function

Every approve and reject goes through one function. Role check, step check, idempotency and concurrency all live here, inside a transaction.

import { Prisma, PrismaClient } from "@prisma/client";

const prisma = new PrismaClient();

type Decision = "APPROVE" | "REJECT";

export async function decide(params: {
  requestId: string;
  actor: { id: string; roles: Role[] };
  decision: Decision;
  idempotencyKey: string;
  comment?: string;
}) {
  const { requestId, actor, decision, idempotencyKey, comment } = params;

  return prisma.$transaction(
    async (tx) => {
      // 1. Idempotency: same key, same answer.
      const existing = await tx.approvalAction.findUnique({ where: { idempotencyKey } });
      if (existing) {
        return tx.approvalRequest.findUniqueOrThrow({ where: { id: requestId } });
      }

      const request = await tx.approvalRequest.findUniqueOrThrow({
        where: { id: requestId },
        include: { workflow: true },
      });
      if (request.status !== "PENDING") {
        throw new Error("Request is no longer pending");
      }

      const def = getWorkflowDef(request.workflow.key);
      const step = def.steps[request.currentStep];
      if (!step) throw new Error("No active step");

      // 2. Role and separation-of-duties checks, on the server.
      if (!actor.roles.includes(step.role)) {
        throw new Error("Not allowed to act on this step");
      }
      if (actor.id === request.requesterId) {
        throw new Error("Cannot approve your own request");
      }

      // 3. Record the decision. Unique constraints back this up.
      await tx.approvalAction.create({
        data: {
          requestId,
          stepOrder: request.currentStep,
          actorId: actor.id,
          type: decision,
          comment,
          idempotencyKey,
        },
      });

      const isLastStep = request.currentStep === def.steps.length - 1;
      const next =
        decision === "REJECT"
          ? { status: "REJECTED" as const }
          : isLastStep
            ? { status: "APPROVED" as const }
            : { currentStep: request.currentStep + 1 };

      // 4. Optimistic concurrency: only update the version we read.
      const updated = await tx.approvalRequest.updateMany({
        where: { id: requestId, version: request.version },
        data: { ...next, version: { increment: 1 } },
      });
      if (updated.count !== 1) {
        throw new Error("Request changed, please retry");
      }

      // 5. Side effects run in the same transaction.
      if (decision === "APPROVE" && isLastStep && def.onComplete) {
        await def.onComplete({
          tx,
          requestId,
          requesterId: request.requesterId,
          payload: def.validate(request.payload),
          enqueueEmail: (msg) => enqueueEmailInTx(tx, msg),
        });
      }

      return tx.approvalRequest.findUniqueOrThrow({ where: { id: requestId } });
    },
    { isolationLevel: Prisma.TransactionIsolationLevel.Serializable },
  );
}
Enter fullscreen mode Exit fullscreen mode

A few choices worth calling out:

  • Belt and braces. The idempotency lookup handles the polite retry. The unique constraints handle the impolite race. The version check catches two approvers acting on the same step at once.
  • Separation of duties is explicit. "Can't approve your own request" is one line. It's also exactly the line AI-generated code tends to leave out.
  • Audit by construction. ApprovalAction is append-only, so "who approved this and when" is a query, not an investigation.
  • One place to test. Leave, salary certificates and advance salary all go through decide. Get it right once and every flow benefits.

Ideas for hardening it further down the line: delegation when an approver is on leave, escalation for stuck requests, and versioning workflow definitions so in-flight requests keep the rules they started with.

Deploying it

The portal runs on AWS EC2 with PostgreSQL behind it. For a short build, my bias is the simplest deploy that's repeatable, and here's what I'd insist on: one build command, migrations applied as an explicit step and not on app boot, a database backup before any migration, TLS in front, and a process manager that restarts the app if it dies. Platform engineering habits don't switch off because the deadline is short. If anything, they matter more.

What I took from it

AI made the two-day timeline possible. It didn't make the product trustworthy. The things that made people willing to use it, with the right people approving, correct balances and correct money, came from deciding which parts got careful human attention.

If you're looking at a Forward Deployed Engineer role, or any role where you build directly for the business, this is the skill I'd practise. Not prompting. Judgement about where AI output is good enough and where it has to be proven.

The company took the job ad down and adopted the portal. About 700 people use it. That's the outcome I care about more than the two days.

I lead platform engineering for fintech. Read the full case study here or see how we can work together at Nazmul Ahasan.

Top comments (0)