About this project · Engineering case study

Photography Portfolio
& Inquiry Application

A photography portfolio with reusable series and collections, durable inquiry receipts, and retries that protect against duplicate requests.

Independent projectLive on Vercel
Recorded 43 automated checks passed
Controlled production testInbox receipt verified

Overview

AlanLi.photo is a photography portfolio and inquiry application for my portrait work. Visitors can explore a series, bring one visual reference into a conversation, and get in touch without finalizing a date or a detailed brief.

The inquiry confirms receipt after durable storage, not a booking. Functional checks use synthetic data; provider acceptance and actual inbox delivery are separate outcomes.

My contribution

My role

Product scope
Defined the portfolio → reference → inquiry flow, with booking, payments, accounts and a production admin UI outside this revision’s scope.
Application
Developed the reusable series/collection catalog, local curation Studio, public routes, inquiry references, validation, durable storage, duplicate protection and notification state with AI assistance.
Validation
Maintained and ran automated checks for catalog integrity, retries and failure states; checked browser interactions and a separately authorized production delivery test.

The project combines my photography with an application I maintain for that work. This revision, developed with AI assistance, reorganizes the portfolio, connects a selected series to an inquiry, and adds validation, durable reception, duplicate protection, and an operational path for notifications.

It builds on the existing Next.js / React application, Inter typography, the initial nine-series catalog, and Resend integration. One metadata file per source series controls status, stable IDs, slugs, listing and route visibility, default covers and photograph sequences. A separate collection file stores an explicitly ordered list of source image IDs, without copying photo records or binaries. Generated dimensions support uncropped detail layouts, and series share one page template. A local Studio edits Hero selections, covers, featured order and desktop/mobile crops through a separate curation file; source photographs, series URLs and inquiry references stay intact.

The Vercel release updates Next.js to 15.5.24 and React to 19.2.8. Next.js provides rendering and image optimization; React handles the interface; libSQL provides database transactions; Resend handles email transport. The application does not implement these services from scratch.

User flow

Selected work or the complete Work index → open a shareable series page → optionally enlarge a photograph → choose “I want a session like this” → enter a name and email → submit → receive confirmation after the inquiry and pending notification are stored together.

Dates and messages are optional. The series link carries a stable public reference into the existing home form. It can be removed, and an inquiry also works without one. The server resolves the reference against the same public catalog used by the gallery.

Local preview of the Bridge series page, showing its title, location and photographs at their original proportions.

Explore a series

Open a series at its own shareable URL, scroll through uncropped photographs, and enlarge any image before getting in touch.

Local inquiry form containing the Bridge reference and synthetic example details.

Carry the reference

Bridge appears as a removable reference. Only a name and email are required; dates and plans can stay open.

Local success message confirming inquiry receipt and explaining that the session is not yet booked.

Confirm receipt

The inquiry is saved before this confirmation appears. The message makes clear that a session is not yet booked.

Local preview · September 9, 2026 · Synthetic inquiry details · Real email disabled. Select a screenshot to view it at full size.

Engineering decisions

Two constraints shaped the implementation: inquiries must survive an email failure, and retrying an uncertain request must not silently create a second inquiry.

01 — Receipt and notification are separate

Problem
An email failure must not lose an inquiry.
Decision
Save the inquiry and pending notification together before acknowledging receipt.
Tradeoff
Pending, failed and uncertain notifications still need operator review.

Email availability should not determine whether an inquiry is lost. A transaction writes the inquiry and its pending notification state before the API returns a minimal receipt. The public API does not expose customer records. In the production configuration, it awaits a bounded initial email attempt after saving, rather than launching untracked background work.

The request handler attempts the first notification, and a private operator command can process pending jobs and explicit retries. It records accepted, failed, or unknown results separately. A worker claims a record atomically; an interrupted worker or uncertain provider response requires review before another send. “Accepted” means the provider accepted the request, not that an inbox received it.

This keeps the implementation small without introducing a separate queue service or admin dashboard. The tradeoff is operational: pending, failed and unknown records still need operator review. A saved inquiry remains available even when the provider fails or a request is interrupted. Provider acceptance is never presented as proof of inbox delivery.

02 — Retrying a request is not a new inquiry

Problem
An uncertain response can lead a visitor to submit the same request again.
Decision
Use a unique request ID and a content hash to distinguish retries from changed requests.
Tradeoff
Duplicate protection lasts while the record is retained; it is not permanent.

A random request ID travels with a submission. The database enforces uniqueness, while a hash of the normalized content detects an ID reused with changed details. Concurrent retries receive the same minimal acknowledgement; they do not overwrite a request or expose private data.

The form retains its contents and its request ID after an uncertain response. If the visitor changes the details after that uncertainty, the interface asks them to deliberately start a new inquiry. A fresh request from the same email is allowed, subject to a modest rate limit.

Deduplication lasts while the stored record is retained, with a minimum 90-day cleanup threshold for resolved records. Unresolved notifications remain for review. This is a bounded guarantee, not a promise of permanent deduplication or exactly-once email delivery.

03 — Photo delivery and inquiry data have different lifecycles

Problem
Photography publishing and transactional inquiries need different storage workflows.
Decision
Keep a versioned photo catalog and immutable image URLs; store inquiries in Turso.
Tradeoff
Publishing stays owner-managed, with source archives maintained separately.

A validated, version-controlled catalog controls the owner-managed series, their stable inquiry IDs, publication and display order. Each source series and collection has its own JSON file. Build preparation rejects duplicate IDs or slugs, invalid covers and ordering, missing alt text, missing or inconsistent delivery dimensions, and collections that reference missing or unavailable sources. Only published content enters the production catalog. Unlisted legacy series retain their URLs, while new source-only shoots do not automatically gain routes or inquiry references. Public Web exports live in Vercel Blob; a generated delivery manifest maps the catalog’s asset keys to immutable URLs and measured dimensions. Turso stores inquiries and notification state rather than image files. No gallery database query or CMS is needed to publish a series.

A local import command reads a selected photo folder, preserves the source files, creates stable photo IDs and a draft series, and creates oriented sRGB WebP exports up to 3000 pixels on the long edge, with a content hash in each filename. Uploads are resumable and a series switches only after each file is downloaded and checked for its hash and dimensions. The retained website source files stay outside the deployed application. They are not a substitute for the separate RAW and Lightroom archive.

The application allows images only from its own Blob hostname. Page generation and metadata refresh use the checked-in delivery manifest without fetching the gallery at build time. Replacing a photo produces a new URL, while stable series IDs preserve inquiry references. This separates content publishing from transactional inquiry storage without adding a production administration interface.

Validation

Automated checks
43 checks passedRecorded 2026-09-27 · Local tests with simulated email.
Browser checks
Desktop + mobileResponsive layouts, keyboard controls and the inquiry flow.
Controlled production test
Inbox receipt verifiedOne controlled test · September 9, 2026. Future delivery is not guaranteed.

Read the automated validation report

The automated suite covers catalog integrity and curation, public routes and references, collection resolution, image delivery metadata, optional fields, malformed input, concurrent retries, changed-content conflicts, restart persistence, unavailable storage, rate limits, notification failure and recovery, and local Studio access, validation and save conflicts. The linked validation report records the run time, environment, source fingerprint and individual results from one generated artifact.

Automated inquiry checks use synthetic data, isolated SQLite databases and simulated email responses. Browser checks cover reading order, responsive layouts, lightbox keyboard controls and focus return, portfolio return links, reference removal and a local inquiry receipt. These checks are distinct from the historical production delivery test below; they do not establish real-user performance or business outcomes.

A separately authorized production test on September 9, 2026 verified one synthetic inquiry through durable storage, Resend acceptance, and actual arrival in the owner’s Gmail inbox. Replaying the same request did not send another notification. This is evidence for that controlled test, not a guarantee that every future email will arrive. The screenshots above were captured separately in local preview with real email disabled.

Limitations

Local development uses a private on-disk SQLite database. Vercel uses a dedicated Turso database with private credentials. The API rejects local file storage on Vercel rather than accepting inquiries into ephemeral files. There is no automatic retry schedule; unresolved notifications are recovered with the private operator tools.

There are no payments, client accounts, live availability, contracts, or production administration pages. The local Studio is available only in development on loopback hosts; saved selections must be committed and deployed to reach the live site. The application begins a conversation; the session scope and timing are confirmed separately.

The homepage currently shows six curated works; /portfolio contains published entries selected for listing, and one reusable /portfolio/[slug] template resolves both series and collections. Source photo records and series image order stay intact; the curation file overrides displayed covers, featured ordering and Hero crops. Additional shoots are maintained as source series. The Graduation collection presents 16 photographs across five source shoots; it references the existing source records rather than duplicating photographs. Category filters and pagination can be added when the catalog grows; they are not needed for the current collection. Responsive image loading is implemented, but no claim is made about a measured performance gain or real-user business outcome. Browser network-loss simulation and a production rollback drill have not been performed.