AI – Inquisitive M365 https://thomasdaly.net Yet another SharePoint / Office 365 blog Thu, 07 May 2026 01:40:55 +0000 en-US hourly 1 116451836 Why We Rebuilt a Power App as a Web App (And How AI Helped Us Ship It Fast) https://thomasdaly.net/2026/03/05/power-app-to-web-app-migration/ https://thomasdaly.net/2026/03/05/power-app-to-web-app-migration/#comments Fri, 06 Mar 2026 00:00:00 +0000 https://thomasdaly.net/?p=3413 Power App shattering into React components flowing into a modern web application

A mom-and-pop camera parts store came to us with a problem. They'd been running their inventory consignment tracking on a Power App built on top of SharePoint lists, and it was falling apart. Not in a dramatic way — more like death by a thousand cuts. Slow screens, security workarounds, lists bumping up against row limits, and a UI that fought them every time they needed to add a feature.

The app tracked consignment memos — when parts went out on consignment to other dealers, what was sent, what came back, what was still outstanding. It's the kind of workflow that sounds simple until you realize it touches inventory, customers, sales staff, PDF generation, email notifications, and reporting. The Power App had gotten them through the first couple of years, but they'd hit a ceiling.

We rebuilt it as a custom web application. And the part that made it feasible for a small team on a reasonable budget was AI-assisted development — not as a gimmick, but as a genuine force multiplier for the grunt work.


What Went Wrong with the Power App

Power Apps are great for getting something running quickly. You connect to a SharePoint list or Dataverse table, drag some controls onto a screen, write a few formulas, and you've got a working app in a day. For simple workflows with a handful of users, that's genuinely powerful.

But this app had grown well past that sweet spot. Here's what broke down:

Speed

The app was painfully slow. Every screen load involved multiple lookups against SharePoint lists, and Power Apps doesn't give you much control over how or when those queries execute. Users would tap a button and wait several seconds for the next screen to render. For a tool people use dozens of times a day, that friction adds up fast.

Delegation was a constant headache. Power Apps delegates certain operations to the data source, but many common operations — like certain filter combinations or sorting on calculated fields — can't be delegated. That means the app silently pulls only the first 500 or 2,000 rows and works with that subset, which led to missing records in search results. Users would search for a memo they knew existed and get nothing back.

The Large List Problem

SharePoint lists have a 5,000-item list view threshold. Once you cross it, queries that aren't indexed start failing or returning incomplete results. The consignment memo list had blown past this limit months ago. We'd added indexes on the most-queried columns, but Power Apps' delegation model meant some queries still couldn't take advantage of them.

The workarounds were ugly — splitting data across multiple lists, pre-filtering with flows, caching subsets locally. Each workaround added complexity and new failure modes.

Too Many Controls

Power Apps renders every control on a screen, even ones that aren't visible. As the screens grew more complex — conditional sections, expandable details, dynamic form fields — performance degraded. We'd hit screens with 200+ controls, and the app would visibly struggle to render them.

The recommended fix is to split screens into smaller pieces, but that creates a disjointed user experience and makes state management even harder. You end up passing data between screens through global variables and collections, which is fragile and hard to debug.

Security Limitations

Power Apps' security model is tied to the underlying data source. With SharePoint, that means item-level permissions or breaking inheritance — neither of which scales well. The business needed role-based access: admins see everything, sales staff see only their stores, viewers get read-only access. Implementing this cleanly in Power Apps required a tangle of conditional visibility rules and duplicate screens, and it was never quite right.

Hard to Hand Off

This is the one that doesn't get talked about enough. Power Apps are notoriously difficult to hand off to another developer. There's no proper source control. The "code" is a mix of Excel-like formulas scattered across control properties. There's no way to do a meaningful code review or diff. Documentation is whatever comments you've added to your formulas — and let's be honest, most Power App formulas don't have comments.

When the original developer moves on, the next person has to reverse-engineer the app by clicking through every screen and reading every control's property panel. For a complex app, that can take weeks just to understand what it does, let alone modify it safely.

Hard to Extend

Every new feature request felt like a negotiation with the platform. Need to generate a PDF? You're piping data to a Power Automate flow that calls a third-party connector or a custom API. Need to send styled HTML emails on a schedule? Another flow, another connector, another set of failure points. Need a proper grid with sorting, filtering, and inline editing? You're stacking galleries and buttons and toggle controls in ways they weren't designed for.

The app had become a Rube Goldberg machine. It worked, but adding anything new meant understanding all the existing moving parts and hoping nothing broke.


The Decision to Rebuild

We didn't take this lightly. Rewriting a working application is risky — you're spending money to get back to where you already are before you can move forward. But the writing was on the wall:

  1. The Power App couldn't scale further. Every new feature was getting harder and slower to build.
  2. Performance was hurting the business. Staff were avoiding the app when they could and falling back to spreadsheets.
  3. Maintenance risk was growing. The original developer's availability was limited, and nobody else could work on it.
  4. The requirements were well understood. After two years of using the Power App, the business knew exactly what they needed. No discovery phase, no guesswork.

That last point is underrated. The Power App had essentially served as a working prototype — an expensive one, but it meant we weren't building from a blank page. We had screens, workflows, and edge cases already mapped out.


How We Built the Replacement

We chose Next.js with TypeScript, Ant Design for the UI, Azure AD for authentication, and Azure SQL Database for storage. The app runs on Azure App Service with Azure Functions handling background jobs like scheduled email notifications.

Requirements and Mockups First

Before writing any code, we documented every feature the Power App had — and every feature they wished it had. We walked through the existing app screen by screen, cataloging functionality, business rules, and pain points. This gave us a clear requirements document and a set of UI mockups to build against.

This step matters more than people think. When you're using AI to help generate code (more on that in a minute), the quality of what you get out is directly proportional to the quality of what you put in. Vague requirements produce vague code. Detailed requirements with mockups produce components that are 80-90% right on the first pass.

AI-Assisted Development

Here's where it gets interesting. This was a solo developer project with a tight timeline. Building a full-featured web application — authentication, role-based access, CRUD operations, PDF generation, email automation, responsive UI — would normally take months of dedicated work.

Our AI tool of choice was Claude on the Max plan ($100/month). That's it — one subscription, one developer, and a clear set of requirements. We used it extensively throughout the build. Not to replace the developer, but to accelerate the tedious parts:

Scaffolding and boilerplate. Setting up API routes, database queries, TypeScript types, form validation — the kind of code that's necessary but not creative. With clear requirements, we could describe what an API endpoint needed to do and get a working implementation that just needed review and testing.

UI components. Given a mockup and a component library (Ant Design), generating the initial component code was fast. "Build a form page with these fields, these validation rules, and this layout" produces something usable quickly. The developer's job shifted from writing every line to reviewing, adjusting, and integrating.

Database migrations. Translating a data model into SQL migration scripts, complete with indexes and constraints. Describing the schema in plain English and getting back working DDL.

Repetitive patterns. Once we'd established how one CRUD feature worked (API route, service layer, component, types), generating the next five features followed the same pattern. The AI was good at applying established conventions consistently.

Security hardening. We ran the codebase through security review — JWT validation, XSS protection, SQL injection prevention, input sanitization, security headers. The AI identified vulnerabilities and generated fixes, which we then verified.

What AI didn't do well: architectural decisions, complex business logic edge cases, performance optimization, and anything requiring deep understanding of how the pieces fit together. Those still required an experienced developer thinking carefully.

The net effect was that the core application was rebuilt in a few weeks. But here's the part that doesn't make the highlight reel: we spent significantly more time on validation than on building. The goal wasn't just feature parity — it was parity or better on every workflow. That meant going through every screen, every edge case, every business rule from the Power App and verifying the new app handled it at least as well, if not better.

QA Process

Building fast with AI makes a rigorous QA process non-negotiable. The build phase was the easy part — the validation phase was where the real time went. When code is generated rather than hand-typed, you can't rely on the "I wrote it so I understand it" safety net. We implemented:

  • Code review on every generated component. Nothing went in without the developer reading and understanding it line by line.
  • Manual testing against the requirements doc. Every feature was tested against its original requirement, not just "does it compile."
  • Security review. We did a dedicated security pass, treating the app as if it were written by an untrusted junior developer. We found and fixed JWT validation gaps, missing input sanitization, console logging of sensitive data, and transaction handling issues.
  • User acceptance testing. The actual users (the store staff) tested the app against their real workflows before go-live. Their feedback drove the final round of adjustments.
  • Side-by-side comparison. We ran both the Power App and the web app in parallel for two weeks, ensuring the new app handled every scenario the old one did.

What We Gained

The difference was night and day:

Speed. Pages load in under a second. Search is instant, even across tens of thousands of records. No delegation limits, no 500-row caps, no phantom missing results.

Polish. This was the surprise win. With a proper component library and consistent UI patterns, the web app ended up far more polished than the Power App ever was. We could add modal dialogs for editing contacts, sales staff, and store details — all inline, without navigating away from the current screen. Form layouts were consistent across every page. Loading states, validation feedback, error messages — all uniform. In Power Apps, that level of UI consistency is a constant battle against the platform. In a web app with a design system like Ant Design, it's the default.

Security. Proper JWT-based authentication with Azure AD, role-based access control at the API level, parameterized queries, XSS protection, and security headers. The kind of security that's table stakes for a web app but nearly impossible to retrofit onto a Power App.

Maintainability. It's a standard Next.js/TypeScript codebase in a git repository. Any developer who knows React can pick it up. There are pull requests, code reviews, diffs, and a clear project structure. The README explains how to run it. The CLAUDE.md documents the architecture for AI-assisted future development.

Extensibility. Need a new feature? It's a new API route and a new component, following established patterns. Need to change the PDF layout? It's a pdfmake template in code, not a Power Automate flow calling a third-party connector. Need scheduled emails? It's an Azure Function with an HTML template. Need a new modal for managing a related entity? Copy an existing one, adjust the fields, done.

CI/CD. We set up deployment pipelines for both dev and production environments. Push to a branch, it deploys to dev. Merge to main, it goes to production. The Power App had… "publish."


When Power Apps Still Makes Sense

This isn't a "Power Apps is bad" article. Power Apps is the right tool when:

  • Your data fits comfortably in SharePoint or Dataverse limits
  • You have a small number of users (under ~20)
  • The app is simple — a few screens, straightforward data entry, basic reporting
  • You need something running this week, not this quarter
  • The builder will also be the maintainer for the foreseeable future

The camera parts store's app had simply outgrown all of those conditions. It had thousands of records, complex business rules, multiple user roles, PDF generation, automated emails, and needed to be maintainable by someone other than the original builder.


The AI Development Takeaway

The real story here isn't "Power Apps bad, custom code good." It's that AI-assisted development has changed the build-vs-buy calculation. Two years ago, rebuilding this app as a custom web application would have been hard to justify for a small business. The development cost would have been too high relative to the pain of living with the Power App's limitations.

But with Claude Max at $100/month and a single experienced developer, we built a production-quality application in a few weeks. The real investment was in validation — weeks more of meticulous testing to ensure parity or better on every workflow. The developer's role shifted from writing every line of code to architecting the solution, defining the requirements clearly, reviewing generated code, and handling the genuinely complex parts that require human judgment.

That's the pattern I expect to see more of: businesses that started with low-code tools outgrowing them, and AI-assisted development making the jump to custom code practical on a small-business budget. The key ingredients are clear requirements (ideally from having already run the low-code version), an experienced developer who can architect and review, and a disciplined QA process that doesn't trust generated code blindly. The build is the fun part. The validation is where you earn the trust.

The camera parts store is now running on an app that's faster, more secure, easier to extend, and maintainable by any competent developer. Total AI tooling cost: a few months of a $100 subscription. That's not magic — it's the practical reality of knowing what to build and having better tools to build it with.

]]>
https://thomasdaly.net/2026/03/05/power-app-to-web-app-migration/feed/ 1 3413
Building a Two-Stage AI Pipeline for Invoice Processing with AWS Textract and Amazon Bedrock https://thomasdaly.net/2025/04/04/textract-plus-bedrock-two-stage-ai-pipeline/ https://thomasdaly.net/2025/04/04/textract-plus-bedrock-two-stage-ai-pipeline/#comments Sat, 05 Apr 2025 00:00:00 +0000 https://thomasdaly.net/?p=3384 Processing PDF invoices by hand doesn't scale. I was working on a reconciliation project where the finance team had thousands of PDF invoices that needed to be matched against records in their accounts payable system. The data lived in the PDFs — invoice numbers, dates, totals, vendor names — but getting it out meant someone had to open each one, find the fields, and type them into a spreadsheet. It was slow, error-prone, and took way too much time every month.

I wanted to automate the extraction end to end. Drop a PDF in, get structured data out. No manual intervention, no per-format configuration. The system needed to handle invoices, statements, contracts — whatever showed up in the inbox.

The twist was that these invoices came from hundreds of different vendors, and every single one formats their invoices differently. Different layouts, different labels, different date formats, different ways of presenting totals. Writing extraction rules per vendor wasn't going to work — there were too many, and new ones showed up all the time.

What I ended up building was a two-stage AI pipeline using AWS Textract for OCR and Amazon Bedrock for the AI normalization layer. Textract reads the page. Bedrock figures out what it all means and boils every format down to one consistent JSON structure — regardless of how the vendor laid out the invoice.

Intelligent Document Processing Pipeline Architecture — S3 inbox to SQS to Lambda (Textract + Bedrock) to S3 processed and RDS database

The Real Problem: Every Vendor's Invoice Looks Different

This was the fundamental challenge. We weren't dealing with one invoice format — we were dealing with hundreds. Every vendor sends their invoices laid out differently. Some have the invoice number at the top right. Others bury it in a table. Some label it "Invoice #", others call it "Reference No." or "Document ID." Dates show up as "01/15/2025," "January 15, 2025," "2025-01-15," or "15 Jan 25." Totals might be in a summary box, a footer line, or the last row of a table.

If you try to solve this with traditional OCR alone, you end up writing extraction rules for each vendor's format. That might work for your top 10 vendors, but when you have hundreds — and new ones showing up regularly — it's a losing game. You'd spend more time maintaining the rules than you'd save on manual entry.

That's where the AI comes in. The AI is the normalizer. It doesn't care that Vendor A puts the invoice number in the header and Vendor B puts it in line 3 of a table. You give it the raw extracted text and tell it: "Find me the invoice number, the date, the total, and the vendor." It figures out which field is which regardless of layout, labeling, or format — and returns it in a single consistent JSON structure every time.

Why Two AI Services Instead of One?

So if the AI handles the normalization, why not just send the PDF directly to the model and skip OCR entirely?

I actually tried this first. Bedrock supports direct PDF input — you base64-encode the file and send it straight to the model. But there are practical limits. Bedrock caps direct PDF input at 5MB, and a lot of real-world invoices — especially scanned multi-page documents — blow right past that.

More importantly, even when the files were small enough, the results were inconsistent. The model would miss table data, misread amounts, or confuse fields that were visually close together on the page. Out of roughly 15,000 documents, the direct-to-Bedrock approach failed on close to 100% of them when used as the primary path. It was trying to solve two problems at once, and neither one got the attention it needed.

The two problems are:

  1. The spatial problem — Where is the text on the page? What's a form label vs. a value? What belongs to which table column? This is a vision problem.
  2. The semantic problem — Out of all these fields, which one is the invoice number? Is this date the invoice date or the due date? Is this amount the subtotal or the total? This is a comprehension problem.

Textract is purpose-built for the spatial problem. It doesn't just do OCR — it identifies key-value pairs (like "Invoice Date: 12/15/2025"), detects table structures with rows and columns, and maps out form fields. It understands the layout of the page in a way that a general-purpose language model can't reliably match.

Bedrock handles the semantic problem — the normalization. I take all that structured data from Textract and hand it to the AI model. Now the model doesn't have to figure out where things are on the page. It just has to look at a list of key-value pairs and lines of text and decide: "This is the invoice number. This is the date. This is the total." And it does that consistently, regardless of whether the invoice came from a massive distributor or a one-person shop with a Word document template.

The combination means Textract does what it's best at (reading the page), and the AI does what it's best at (understanding the content and normalizing it into a single format). Neither one alone would have worked nearly as well.

The AWS Architecture

The whole pipeline is serverless. Drop a PDF into an S3 bucket, and everything else happens automatically — no servers to manage, no workers to monitor.

Serverless IDP Pipeline — S3 inbox to SQS to Lambda with Textract and Bedrock, showing the S3 prefix lifecycle from inbox to processing to processed

Here's the flow:

  1. PDF lands in S3 — uploaded to the inbox/ prefix of an S3 bucket
  2. S3 Event Notification fires — triggers automatically when a new object appears in inbox/
  3. SQS Queue receives the event — buffers the work and handles retries if the Lambda fails
  4. Lambda picks up the message — runs Textract, sends results to Bedrock, writes output to S3 and the database
  5. PDF moves through prefixesinbox/processing/processed/ (or error/ if something fails)

The S3 prefix movement is a nice pattern. You always know the state of a document by where it sits in the bucket. If something fails mid-processing, you can see exactly which files are stuck in processing/ and investigate.

The Lambda: Textract + Bedrock in One Function

The Lambda function does all the heavy lifting. When an SQS message arrives, it parses the S3 event, grabs the PDF, and runs it through both AI stages.

Step 1: Run Textract with FORMS + TABLES

const result = await textract.send(
  new StartDocumentAnalysisCommand({
    DocumentLocation: {
      S3Object: { Bucket: bucket, Name: key }
    },
    FeatureTypes: ["FORMS", "TABLES"]
  })
);

The FORMS and TABLES feature types are key. Basic OCR just gives you text. With these enabled, Textract identifies key-value pairs (like "Invoice Date: 12/15/2025") and detects table structures with rows and columns. This structured data is what makes the Bedrock step so much more accurate.

Raw Textract blocks on the left transformed into a clean structured payload with keyValues, tables, and lines on the right

Step 2: Send to Bedrock for AI Extraction

Here's where the normalization happens. I send the structured Textract data to Amazon Bedrock with a prompt that tells the model exactly what I need back.

The prompt is specific about the output format:

You extract key information from invoices or invoice-like documents.
Focus on identifying the invoice number - it is the most important field.

Return ONLY valid JSON matching exactly:
{
  "document_type": "invoice|statement|contract|other",
  "specific_number": {
    "label": "Invoice Number|Claim Number|PO Number|...",
    "value": "string or null",
    "confidence": 0.0
  },
  "key_fields": {
    "date": null, "due_date": null,
    "total": null, "tax": null,
    "vendor": null, "customer": null
  },
  "summary": "",
  "notes": []
}

Rules:
- Confidence is 0.0 to 1.0. If not found, value=null and confidence=0.
- ALL dates MUST use format MM/DD/YYYY.
- ALL dollar amounts MUST use format $X,XXX.XX.

Getting the prompt right is critical. This is the part that took the most iteration. The AI will do exactly what you tell it to — and if you're not specific enough, you'll get inconsistent results across thousands of documents.

A few things I learned through trial and error:

  • Define the exact JSON schema. I tell the model exactly what shape to return. No extra keys, no variations. Without this, some documents come back with extra fields, others are missing fields, and your parsing code has to handle every variation. Lock down the schema and the output is predictable.
  • Enforce formats in the prompt. Dates as MM/DD/YYYY, amounts as $X,XXX.XX. This is huge. Without explicit format rules, the AI will return dates in whatever format it finds on the document — "January 15, 2025" from one vendor, "2025-01-15" from another, "01/15/25" from a third. The whole point of this pipeline is normalization, so the prompt has to enforce it. If you don't do this here, you end up writing format conversion code downstream for every variation.
  • Confidence scores. The model rates its own confidence on the invoice number. A 0.95 means it's pretty sure. A 0.4 means the document might not even have an invoice number. This is valuable downstream for deciding what needs human review vs. what can flow through automatically.
  • Give the model a place to flag problems. The notes array lets the AI say "multiple invoice numbers found" or "document appears to be a statement not an invoice." Without this, the model silently picks one and you never know it was uncertain. Much better to have it tell you.

Step 3: Write the Results

The Lambda writes a .ai.json file to the processed/ prefix in S3 and upserts a database record using a SQL MERGE on source_file — so reprocessing the same PDF updates instead of duplicating. After writing, the PDF moves from processing/ to processed/, or to error/ if something fails.

S3 bucket prefix structure showing inbox, processing, processed, and error prefixes with PDF and JSON files in each

The Textract Fallback — When OCR Fails

This one surprised me. Some PDFs just don't work well with Textract. Scanned documents with poor quality, unusual layouts, or PDFs that are actually just embedded images. Textract would time out or return very few blocks.

Rather than failing the entire document, I built a fallback: send the PDF directly to Bedrock. Bedrock can accept PDFs directly (up to ~5MB). The extraction quality isn't as good as the two-stage approach — the model has to handle both the spatial and semantic problems — but it's significantly better than returning nothing.

The .ai.json output records whether Textract was used or if it fell back to direct PDF ("status": "FALLBACK_PDF"), so you can audit which documents might need a closer look.

This is a non-ideal workaround. I'm pointing this out for transparency. The direct PDF path loses the structured key-value pairs and table data that Textract provides. But in practice, getting 80% of the fields from a difficult document is better than getting 0%.

When Everything Fails — The Error Pipeline Matters

Here's something that doesn't get talked about enough in AI pipeline posts: you need a plan for errors, not just results.

Out of ~15,000 documents, the vast majority processed fine through the two-stage pipeline. But a meaningful number didn't — Textract timed out, Bedrock returned malformed JSON, the PDF was corrupt, the file was too large, or the document just wasn't an invoice at all (contracts, cover letters, blank pages).

When both Textract and the direct PDF fallback fail, the document lands in the error/ prefix in S3 with a detailed .error.json file next to it. That file captures what went wrong — which stage failed, the error message, timestamps. This gives you a clear picture of what needs attention.

But here's the reality: some documents still end up requiring manual entry. No pipeline handles 100% of real-world data. The goal isn't zero errors — it's making the error path visible and manageable. You want to know exactly which documents failed, why they failed, and have a clean way to either fix and reprocess them or route them to a person.

A few things that helped:

  • Error sidecar files per stage. If Textract fails, a .textract-error.json gets written. If Bedrock fails, a .bedrock-error.json gets written. You can tell at a glance which stage broke.
  • The error/ prefix preserves the original folder structure. If a file was in inbox/vendor-123/invoice.pdf, it lands in error/vendor-123/invoice.pdf. Easy to find, easy to reprocess — just move it back to inbox/.
  • Error counting scripts. I built simple scripts to scan the error files and produce counts by error type. This tells you if you have a systemic issue (Textract throttling, Bedrock model changes) vs. one-off bad files.

The pipeline that processes 14,800 out of 15,000 documents automatically is valuable. But the system that tells you exactly which 200 need human attention — and why — is what makes it production-ready.

What I'd Do Differently

A few things I've been thinking about for the next iteration:

  • Store dates and amounts as proper types. Right now dates are nvarchar and amounts are varchar with dollar signs. This means every comparison requires TRY_CAST and REPLACE gymnastics. It works, but it's fragile. I'd normalize these at write time in the Lambda instead.
  • Add a review queue for low-confidence extractions. The confidence scores are there but nothing surfaces the low-confidence results for human review yet. A second SQS queue that catches anything under 0.7 confidence and routes it to a review UI would be a natural next step.
  • Dead letter queue for persistent failures. Right now failed documents land in the error/ prefix in S3, which works but requires manual inspection. A DLQ with CloudWatch alarms would make this more operationally solid.

Conclusion

The core problem was never "how do I OCR a PDF." It was "how do I take invoices from hundreds of vendors — all with different layouts, labels, and formats — and normalize them into one consistent structure." That's the problem the two-stage pipeline solves.

Textract handles the reading. The AI handles the normalization. You don't write rules per vendor. You don't maintain a mapping table of "Vendor A calls it Reference No., Vendor B calls it Invoice #." The model figures that out, and every document comes out the other side in the same JSON format ready for matching.

If you're evaluating AWS services for document processing, I'd recommend starting with Textract + Bedrock together rather than either one alone. Textract alone gives you raw data in whatever format the vendor decided to use. An LLM alone struggles with the spatial layout of real documents. The combination is where it clicks.

I'm still iterating on the matching and reconciliation side of this — automatically pairing the extracted data with accounts payable import records using vendor number, date, and amount as a composite key. If there's interest, I'll cover that in a follow-up post.

Have you dealt with multi-format invoice extraction? I'd be curious whether you went the rules-based route or let AI handle the normalization. Drop a comment below — I'd love to hear what worked and what didn't.

Invoice reconciliation dashboard showing matched, manual review, and unmatched statuses across vendor invoices
]]>
https://thomasdaly.net/2025/04/04/textract-plus-bedrock-two-stage-ai-pipeline/feed/ 1 3384