Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@ on:
push:
branches:
- main
- dev
pull_request:
branches:
- main
- dev

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
Expand Down Expand Up @@ -69,7 +71,7 @@ jobs:

cd:
needs: ci
if: ${{ github.event_name == 'push' }}
if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
permissions:
contents: read
packages: write
Expand Down
18 changes: 0 additions & 18 deletions apps/api/deploy/.env.example
Original file line number Diff line number Diff line change
@@ -1,33 +1,15 @@
# Copy to .env next to compose.yaml on the VM (/opt/apps/snapsplit) and fill in.
# docker compose reads this file for the api service. NEVER commit the real .env
# — it holds secrets.

# Image tag to pull for the api service. Use a published tag, e.g. latest, or a
# sha-<7> produced by `bun nx publish api`. The deploy script only rewrites this
# line on the VM.
API_IMAGE_TAG=latest

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stripping the comments leaves this file undocumented anywhere in the repo — docs/deployment.md is gone, README.md has no deploy section, and docs/ now holds only dcu.puml and srs.md. This is the file a human copies to /opt/apps/snapsplit/.env and fills in by hand, and the removed lines carried precisely the non-obvious parts: that CORS_ORIGIN is the Cloudflare Worker origin rather than the API's own domain, why TRUST_PROXY=1 is required (without it the rate limiter keys every caller by Caddy's address and throttles them as one), and the terraform output commands that source the five S3_* values. Note apps/api/.env.example kept its comments, so the two files are now inconsistent.


# Base DuckDNS domain; the caddy-docker-proxy label builds snapsplit-api.<domain>.
DUCKDNS_DOMAIN=your-subdomain.duckdns.org

# API runtime
NODE_ENV=production
# One proxy (Caddy) in front of the API; without this the rate limiter keys every
# caller by Caddy's address and throttles them as one.
TRUST_PROXY=1
# Web origin allowed by CORS. This is the Worker URL (or its custom domain), NOT
# the API's own domain. A missing origin fails at request time in production.
CORS_ORIGIN=https://example.workers.dev
# Gemini key for receipt extraction.
GOOGLE_GENERATIVE_AI_API_KEY=your-gemini-api-key

# MongoDB Atlas (M0 free tier)
MONGODB_URI=mongodb+srv://<user>:<pass>@<cluster>.mongodb.net/snapsplit?retryWrites=true&w=majority
DATABASE_NAME=snapsplit

# Receipt object storage (S3-compatible OCI Object Storage). Fill from:
# terraform -chdir=infra output s3_endpoint s3_region s3_bucket s3_access_key_id
# terraform -chdir=infra output -raw s3_secret_access_key
S3_ENDPOINT=https://<namespace>.compat.objectstorage.<region>.oraclecloud.com
S3_REGION=<region>
S3_BUCKET=snapsplit-receipts
Expand Down
2 changes: 2 additions & 0 deletions apps/api/scripts/publish.sh
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ exec docker buildx build \
--file apps/api/Dockerfile \
--target prebuilt \
--platform linux/amd64,linux/arm64 \
--cache-from "type=registry,ref=$image:buildcache" \
--cache-to "type=registry,ref=$image:buildcache,mode=max" \
Comment on lines +18 to +19

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The prebuilt target has nothing to cache. In apps/api/Dockerfile, prebuilt is FROM runtime (distroless/base-debian13) plus a single COPY dist/apps/api-linux/${TARGETARCH}/server — the deps and build stages aren't in that graph at all, because the cross-compile already happened outside Docker in the build:linux-amd64/build:linux-arm64 targets that publish depends on. The only non-base layer is the compiled binary, whose digest changes on exactly the commits that make api affected, so --cache-from can never hit; meanwhile --cache-to defaults to ignore-error=false, so a GHCR cache-export hiccup fails publish after the image has already pushed and blocks the deploy job that needs it.

Technical details
# Registry buildcache is unreachable for `--target prebuilt`

## Affected sites
- `apps/api/scripts/publish.sh:18-19` — `--cache-from` / `--cache-to type=registry,ref=$image:buildcache,mode=max`, applied to a `docker buildx build --target prebuilt`.
- `apps/api/Dockerfile` — `prebuilt` is `FROM runtime`, which is `FROM gcr.io/distroless/base-debian13:nonroot`. BuildKit only builds stages reachable from `--target`, so `deps` (`bun ci`) and `build` (`compile.sh`) are never instantiated.
- `apps/api/project.json` — `publish` `dependsOn` `build:linux-amd64` and `build:linux-arm64`, both nx-cached, both running `bun build --compile` on the host.

## Required outcome
- CD does not carry cache flags that cannot yield a hit, and a cache-export failure cannot fail a publish whose image push already succeeded.

## Suggested approach (optional)
- Simplest: drop both flags. The expensive work is already memoised by the nx cache on `build:linux-*`, and the real Docker build cost lives in the `hermetic` target, which `publish.sh` does not use.
- If you want to keep them as a hedge, at minimum set `ignore-error=true` on `--cache-to` and drop `mode=max` to `mode=min` — with no intermediate stages in the graph, `max` buys nothing over `min`.

## Open questions for the human
- Was the intent to speed up `hermetic` (which does run `bun ci` + `compile.sh` in-image) rather than `prebuilt`? If CI is meant to move to `hermetic`, the cache flags make sense there and this comment becomes a sequencing note instead.

"$@" \
--label "org.opencontainers.image.source=https://github.com/$GITHUB_REPOSITORY" \
--label "org.opencontainers.image.revision=$GITHUB_SHA" \
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export function LineItemRowShell({
<li
className={cn(
'relative border-b border-border px-4 pb-5',
flag ? 'border-l-[3px] border-l-warning pt-8' : 'pt-5',
flag ? 'border-l-3 border-l-warning pt-8' : 'pt-5',
muted && 'bg-background',
)}
>
Expand Down
7 changes: 3 additions & 4 deletions apps/web/src/features/session/components/receipt-capture.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,14 +61,14 @@ export function ReceiptCapture() {

function AnalyzingView({ imageUrl }: { imageUrl: string }) {
return (
<div className="mx-auto flex min-h-screen w-full max-w-[640px] flex-col">
<div className="mx-auto flex min-h-screen w-full max-w-160 flex-col">
<header className="sticky top-0 z-50 bg-background/80 px-5 py-4 backdrop-blur-md">
<h1 className="screen-title text-primary-pressed">
Reading your receipt
</h1>
</header>
<main className="flex flex-1 flex-col items-center justify-center px-5 py-8">
<div className="relative aspect-3/4 w-full max-w-[320px] overflow-hidden rounded-lg border border-border bg-surface shadow-(--shadow-soft)">
<div className="relative aspect-3/4 w-full max-w-80 overflow-hidden rounded-lg border border-border bg-surface shadow-(--shadow-soft)">
<img
src={imageUrl}
alt="Your receipt"
Expand All @@ -92,7 +92,7 @@ type CaptureViewProps = {

function CaptureView({ inputRef, onCapture, errorMessage }: CaptureViewProps) {
return (
<div className="mx-auto flex min-h-screen w-full max-w-[640px] flex-col pb-12">
<div className="mx-auto flex min-h-screen w-full max-w-160 flex-col pb-12">
<header className="sticky top-0 z-50 flex items-center justify-center bg-background px-5 py-3">
<Wordmark />
</header>
Expand All @@ -113,7 +113,6 @@ function CaptureView({ inputRef, onCapture, errorMessage }: CaptureViewProps) {
ref={inputRef}
type="file"
accept="image/*"
capture="environment"
className="hidden"
onChange={(event) => {
const image = event.target.files?.[0];
Expand Down
63 changes: 48 additions & 15 deletions apps/web/src/features/session/components/session-draft.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
import { useNavigate } from '@tanstack/react-router';
import { AlertTriangle, Camera } from 'lucide-react';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from 'shadcn-ui/alert-dialog';
import { Button } from 'shadcn-ui/button';
import { useDeleteSession } from '@/features/session/api/delete-session';
import { useUpdateSession } from '@/features/session/api/update-session';
Expand Down Expand Up @@ -109,21 +120,43 @@ function RetakePhotoButton({ session }: { session: Session }) {
const deleteSession = useDeleteSession({ sessionId: session.id });

return (
<Button
variant="secondary"
size="xl"
className="w-[40%] flex-none"
disabled={deleteSession.isPending}
onClick={() =>
deleteSession.mutate(
{ sessionId: session.id },
{ onSettled: () => navigate({ to: '/' }) },
)
}
>
<Camera size={20} />
Retake photo
</Button>
<AlertDialog>
<AlertDialogTrigger
aria-label="Retake photo"
render={
<Button variant="secondary" size="xl" className="w-11 flex-none px-0">
<Camera size={20} />
</Button>
}
></AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Retake photo?</AlertDialogTitle>
<AlertDialogDescription>
This receipt and its items will be discarded so you can take a new
photo.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel variant="ghost" size="xl">
Cancel
</AlertDialogCancel>
Comment on lines +141 to +143

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AlertDialogAction is disabled while the delete is in flight, but AlertDialogCancel isn't. Clicking Cancel in that window closes the dialog as though the retake were aborted, yet the DELETE is already in flight and onSettled still navigates to / a moment later with the session gone — a misleading signal on an irreversible action.

Base UI's AlertDialog already blocks outside-press dismissal (disablePointerDismissal = isAlertDialog || prop), but Escape still dismisses (escapeKey: isTopmost), so this only closes the button path. Fully sealing it would need a controlled open/onOpenChange guard, which is probably not worth the complexity here.

Suggested change
<AlertDialogCancel variant="ghost" size="xl">
Cancel
</AlertDialogCancel>
<AlertDialogCancel
variant="ghost"
size="xl"
disabled={deleteSession.isPending}
>
Cancel
</AlertDialogCancel>

<AlertDialogAction
variant="destructive"
size="xl"
disabled={deleteSession.isPending}
onClick={() =>
deleteSession.mutate(
{ sessionId: session.id },
{ onSettled: () => navigate({ to: '/' }) },
)
}
>
Retake
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

Expand Down