# Edit Ad Campaigns Plan

## Goal

Let advertisers edit campaigns they own after creation so they can act on experiment results by changing the sponsored card title, description, image, destination URL, and UTM tracking fields.

## Recommended UX

Use an edit dialog launched from each campaign card in `/advertise/dashboard`, not a separate edit page.

Rationale:
- The requested edit scope is still a focused campaign-creative/action edit: title, description, creative image, destination URL, and UTM tracking.
- The dashboard already shows the campaign context and analytics users are reacting to.
- Existing app mutation UI commonly uses dialog/inline editors with JSON `PATCH` and `router.refresh()`.
- A separate page would be better later if campaign management expands into scheduling, targeting, budgets, moderation history, or multi-step creative review.

The dialog should include:
- Editable title input.
- Editable description textarea.
- Editable destination URL input.
- Optional UTM fields in a collapsible section, matching the create form.
- Current image preview.
- Optional replacement image upload.
- Save/cancel actions.
- A short notice that active campaign changes go live immediately and remain subject to Petdex ad policies.

## Worktree Setup

Before implementation, work in an isolated worktree from the primary repo at `/Users/cuevaio/projects/petdex`.

Recommended commands after loading the `using-git-worktrees` skill and confirming we are not already in an isolated worktree:

```bash
git worktree add ../petdex-ad-campaign-edit -b feat/ad-campaign-edit
```

Copy env from the primary repo into the worktree. The source env file is `.env`:

```bash
cp -p /Users/cuevaio/projects/petdex/.env ../petdex-ad-campaign-edit/.env
```

Then run from the worktree:

```bash
bun install
```

Do not commit `.env`, `.env.local`, `.env*.local`, or `.clerk/`.

## Data And Permissions

Editable fields for this feature:
- `title`
- `description`
- `imageUrl`
- `destinationUrl`
- `utmSource`
- `utmMedium`
- `utmCampaign`
- `utmTerm`
- `utmContent`

Do not edit in this pass:
- `companyName`
- `contactEmail`
- `packageViews`
- `priceCents`
- payment fields
- `status`

Eligibility:
- Allow edits for `pending_payment`, `active`, `paused`, and `exhausted` campaigns.
- Disallow edits for `deleted` campaigns and rows with `deletedAt` set.
- Do not change status when editing.
- Active campaign edits take effect immediately because `getActiveFeedAds` reads directly from `ad_campaigns`.
- Destination URL and UTM edits also take effect immediately for future clicks because click URLs are built from current campaign fields.

Security requirements:
- Require Clerk auth and derive `userId` from the session only.
- Check ownership with `ad_campaigns.user_id = auth().userId`.
- Return `404` for missing or non-owned campaigns.
- Call `requireSameOrigin(req)` in the mutation route.
- Rate-limit by `userId:campaignId`.
- Never accept owner, payment, package, or status fields from request bodies.
- Continue using `/api/ads/image/presign` for image upload, but server-side validate the submitted public `imageUrl`.

## Files To Modify

### `src/lib/ads/validation.ts`

Add an update validator separate from campaign creation.

Suggested exported type:

```ts
export type ValidAdCampaignUpdateInput = {
  title?: string;
  description?: string;
  imageUrl?: string;
  destinationUrl?: string;
} & Partial<AdUtmFields>;
```

Suggested function:

```ts
export function validateAdCampaignUpdateInput(body: unknown):
  | { ok: true; value: ValidAdCampaignUpdateInput }
  | { ok: false; error: string };
```

Rules:
- Body must be an object.
- `title`, `description`, `imageUrl`, `destinationUrl`, and UTM fields are optional.
- At least one editable field must be present.
- `title`: trim/collapse whitespace, required if present, max 80.
- `description`: trim/collapse whitespace, required if present, max 180.
- `imageUrl`: if present, must pass `isAllowedAdImageUrl`.
- `destinationUrl`: if present, must pass `validateAdDestinationUrl`.
- UTM fields: if present, normalize with `cleanUtmValue`; blank values should clear the stored field.
- Do not require `packageId` or `acceptedTerms` for edits.

### `src/lib/ads/queries.ts`

Add a campaign update helper.

Suggested function:

```ts
export async function updateOwnedAdCampaignCreative(params: {
  id: string;
  userId: string;
  title?: string;
  description?: string;
  imageUrl?: string;
  destinationUrl?: string;
  utmSource?: string | null;
  utmMedium?: string | null;
  utmCampaign?: string | null;
  utmTerm?: string | null;
  utmContent?: string | null;
}): Promise<
  | { ok: true; campaign: { id: string; title: string; description: string; imageUrl: string; destinationUrl: string; updatedAt: Date } }
  | { ok: false; error: "not_found" | "campaign_not_editable" | "nothing_changed" }
>;
```

Implementation notes:
- Query the current owned campaign first.
- Treat non-owned/missing as `not_found`.
- Treat `status === "deleted"` or non-null `deletedAt` as `campaign_not_editable`.
- Compare normalized incoming values against current values and reject no-op edits.
- Update only changed editable fields plus `updatedAt`.

Also update `AdvertiserCampaign` and `getUserAdCampaigns` to include UTM fields so the dialog can edit existing tracking values. Current fields already include `deletedAt`, `status`, `title`, `description`, `imageUrl`, and `destinationUrl`.

### `src/app/api/ads/[id]/route.ts`

Create a new `PATCH` route following existing mutation conventions.

Flow:
- `export const runtime = "nodejs"`.
- `requireSameOrigin(req)`.
- `auth()` and `401` when unauthenticated.
- Await promised route params.
- Rate-limit with a new ad campaign edit limiter.
- Parse JSON and return `invalid_json` on failure.
- Validate with `validateAdCampaignUpdateInput`.
- Call `updateOwnedAdCampaignCreative`.
- Return structured JSON.

Response shape:

```json
{
  "ok": true,
  "campaign": {
    "id": "ad_...",
    "title": "New title",
    "description": "New description",
    "imageUrl": "https://...",
    "destinationUrl": "https://example.com",
    "updatedAt": "..."
  }
}
```

Expected errors:
- `unauthorized` with `401`
- `rate_limited` with `429`
- `invalid_json` with `400`
- validation errors with `400`
- `not_found` with `404`
- `campaign_not_editable` with `400`
- `nothing_changed` with `400`

### `src/lib/ratelimit.ts`

Add a dedicated limiter instead of reusing pet edit limits.

Suggested export:

```ts
export const adCampaignEditRatelimit = createRatelimit({
  redis,
  limiter: Ratelimit.slidingWindow(10, "24 h"),
  prefix: "petdex:ad-campaign-edit",
});
```

### `src/components/ads/ad-campaign-edit-dialog.tsx`

Add a focused client component.

Props:
- `campaign`: `id`, `title`, `description`, `imageUrl`, `destinationUrl`, UTM fields, `status`, `deletedAt`.
- localized labels/error messages, or use `useTranslations("advertise.dashboard.edit")` inside the component.

Behavior:
- Initialize local state from the campaign.
- Allow optional file selection for PNG/JPEG/WebP up to 10 MB.
- If a new image is selected, call `/api/ads/image/presign`, upload with `PUT`, then send the returned public URL in the patch.
- If upload succeeds but PATCH fails, retain the uploaded public URL so retry does not require another upload.
- PATCH only changed fields to `/api/ads/${campaign.id}`.
- On success, close dialog and `router.refresh()`.
- Show upload/save/success/error states.
- Disable save while uploading/saving or when nothing changed.

Use existing visual language from `AdvertiseForm` and existing dialog/editor patterns. Prefer semantic Tailwind tokens such as `bg-surface`, `bg-background`, `text-foreground`, `text-muted-2`, and `border-border-base`.

### `src/components/ads/ad-dashboard.tsx`

Add an edit affordance to each editable campaign card.

Changes:
- Import/render `AdCampaignEditDialog`.
- Show `Edit creative` for campaigns where `status !== "deleted"` and `deletedAt === null`.
- Keep the dashboard mostly server-rendered; only the edit dialog should be client-side.

### `src/i18n/messages/en.json`, `es.json`, `zh.json`

Add dashboard/edit strings.

Suggested keys under `advertise.dashboard`:
- `editCreative`
- `edit.title`
- `edit.body`
- `edit.titleLabel`
- `edit.descriptionLabel`
- `edit.destinationUrlLabel`
- `edit.imageLabel`
- `edit.imageCta`
- `edit.imageHelp`
- `edit.currentImage`
- `edit.save`
- `edit.saving`
- `edit.uploading`
- `edit.saved`
- `edit.cancel`
- `edit.utmTitle`
- `edit.utmHelp`
- `edit.errors.imageTooLarge`
- `edit.errors.nothingChanged`
- `edit.errors.submitFailed`
- `edit.errors.uploadFailed`
- `edit.errors.network`

Run `bun run i18n:check` after editing messages.

## Tests

Add focused coverage where practical.

Recommended validation tests:
- Accept valid title update.
- Accept valid description update.
- Accept valid image update.
- Accept multiple fields.
- Reject empty object.
- Reject blank title.
- Reject blank description.
- Reject disallowed image URL.
- Reject invalid destination URL.
- Normalize blank UTM values to `null`.

Recommended route/query tests if existing setup makes this straightforward:
- Unauthenticated PATCH returns `401`.
- Non-owned campaign returns `404`.
- Deleted campaign returns `campaign_not_editable`.
- No-op edit returns `nothing_changed`.
- Valid owner edit updates only title/description/image fields.
- Request body cannot update `status`, payment, package, or owner fields.

## Manual Verification

From the worktree:

```bash
bun run dev:mock
```

Manual flow:
- Open `/advertise/dashboard` as the mock signed-in user.
- Create or seed a campaign if none exists.
- Confirm each non-deleted campaign has `Edit creative`.
- Open the dialog, change title and description, save, and confirm dashboard updates.
- Replace the image with PNG/JPG/WebP, save, and confirm the dashboard image updates.
- Confirm oversize image is blocked before upload.
- Confirm saving no changes shows or prevents `nothing_changed`.
- Confirm active campaign edits appear in feed ad cards.
- Confirm destination URL and UTM edits affect newly generated click URLs.
- Confirm deleted campaigns do not expose edit controls.
- Confirm direct PATCH cannot update status, package, price, payment, or ownership fields.

Required checks:

```bash
bun run check
bun run i18n:check
bun test
bun run build
```

Use focused tests first if full `bun test` needs external services; tests importing DB code may need `--env-file=.env.mock` per repo notes.

## Implementation Order

1. Create worktree and copy env from the primary repo.
2. Add ad campaign update validation.
3. Add owned campaign creative update query helper.
4. Add ad campaign edit rate limiter.
5. Add `PATCH /api/ads/[id]`.
6. Add client edit dialog.
7. Wire edit dialog into the advertiser dashboard.
8. Add localized messages.
9. Add focused tests.
10. Run checks and manual verification.
