# Action Items Dashboard — Plan

## Goal
Give a non-technical manager a simple page in Connect for working the project's
**action items**: the Forgejo issues labeled `action`, across several repos. She
shouldn't have to use the full Forgejo UI. She should be able to:

- See the open action items (and recently closed ones)
- Open one and read its description and comment thread
- Add a comment
- Attach pictures (to a new item or a comment) and see pictures others attached
- Create a new action item
- See at a glance which items have **new activity since she last looked**

Out of scope: closing/reopening (developers do that in Forgejo), due dates,
assignees, milestones, and *setting* labels. Labels are shown and filterable,
but they're managed in Forgejo.

Forgejo stays the source of truth for the issues. Connect stores only a
short-lived cache and the per-user "last viewed" timestamps. Developers keep
working in Forgejo as usual, and anything she does shows up there right away.

## Decisions
| Topic        | Decision                                                            |
|--------------|---------------------------------------------------------------------|
| Forgejo      | `https://www-s01.apinet.com/codeforge/` → API at `.../codeforge/api/v1` |
| Repos        | Allowlist in `.env`; currently just `hhmanager` ("API Home Health Portal") |
| Label        | `action` (plain label, same name in every repo)                     |
| Her actions  | Create + comment (with photos). No close/reopen                     |
| Fields       | Title, description, comments, images only. No due dates/assignees   |
| Attribution  | `connect-bot` account, body prefixed with `users.agent_name`        |
| Unread       | Per-user last-viewed tracking in Connect (any user with the perm)   |
| Visibility   | Private Forgejo instance, so all repos are private (image proxy required) |
| Notifications| None. Users check the dashboard; Forgejo's own watch emails are unchanged |
| Access       | `Connect.ActionItems` perm, assigned to users as needed             |

## Forgejo API used
| Need                         | Forgejo API                                                            |
|------------------------------|------------------------------------------------------------------------|
| List action items (per repo) | `GET /repos/{owner}/{repo}/issues?type=issues&labels=action&state=all&since=…` |
| One item                     | `GET /repos/{owner}/{repo}/issues/{index}`                             |
| Comments on an item          | `GET /repos/{owner}/{repo}/issues/{index}/comments`                    |
| Recent comments (whole repo) | `GET /repos/{owner}/{repo}/issues/comments?since=…` (unread calc)      |
| Add comment                  | `POST /repos/{owner}/{repo}/issues/{index}/comments` `{body}`          |
| Edit comment (append images) | `PATCH /repos/{owner}/{repo}/issues/comments/{id}` `{body}`            |
| Create item                  | `POST /repos/{owner}/{repo}/issues` `{title, body, labels:[<id>]}`     |
| Edit issue (append images)   | `PATCH /repos/{owner}/{repo}/issues/{index}` `{body}`                  |
| Attach image to issue        | `POST /repos/{owner}/{repo}/issues/{index}/assets` (multipart `attachment`) |
| Attach image to comment      | `POST /repos/{owner}/{repo}/issues/comments/{id}/assets`               |
| Fetch an attachment          | `GET` the attachment's `browser_download_url` (with token)             |
| Render markdown              | `POST /markdown` `{Mode:"gfm", Context:"{owner}/{repo}", Text}`        |
| Label id for `action`        | `GET /repos/{owner}/{repo}/labels` (create needs ids, not names)       |

Auth is a header: `Authorization: token <FORGEJO_TOKEN>`.

`GET /repos/issues/search?labels=action` could return everything in one call, but
it searches every repo the token can see. Looping over the allowlist (a handful of
repos, cached) is more predictable and keeps the allowlist authoritative.

## Architecture
```
 Manager's browser ──► Connect page  (/action-items)
                        │  jQuery calls
                        ▼
                      Connect /do/ endpoints  (backend/actionItems*.php)
                        │  server-side, token from .env
                        ▼
                      Forgejo API  (www-s01.apinet.com/codeforge/api/v1)
```

The browser never talks to Forgejo directly and never sees the token. All Forgejo
calls happen in PHP, so she doesn't need a Forgejo account.

### Config (`.env`)
```
FORGEJO_URL=https://www-s01.apinet.com/codeforge
FORGEJO_TOKEN=...                       # connect-bot's token (real value only in .env / .env.prod)
FORGEJO_REPOS=apiintl/hhmanager        # allowlist of repos on the dashboard (comma-separated)
FORGEJO_ACTION_LABEL=action
```
Friendly names live in a small map in `functions_forgejo.inc.php`
(`'apiintl/hhmanager' => 'API Home Health Portal'`). A repo without a name
falls back to its repo name. Adding a repo later means adding it to
`FORGEJO_REPOS` and the map; nothing else changes.

The service account is **`connect-bot`**. It has push (not admin) access to
`apiintl/hhmanager`. Its token has no `read:user` scope (`GET /user` returns 403),
which is fine because we never need it.

Give the service account access to only these repos (a Forgejo team with write
access to just them). Then the token can't reach anything else, whatever happens
in the app.

### HTTP helper: `code/functions_forgejo.inc.php`
Don't use the existing `api_call()`. It only does POST with form encoding, and
for any `apinet.com/` URL it adds an `auth` field and a custom user agent. Forgejo
lives on `www-s01.apinet.com`, so that field would be injected into every request.
Forgejo also needs GET/PATCH, JSON bodies, multipart uploads and binary downloads.

Add a small dedicated helper:
```php
function forgejo_api($method, $path, $opts = []) // returns ['code'=>, 'json'=>, 'body'=>, 'headers'=>]
// $opts: 'json' => array (JSON body), 'query' => array, 'file' => CURLFile (multipart),
//        'raw' => true (return body bytes, for image proxying)
```
Paths are relative to `FORGEJO_URL . '/api/v1'`. Note the `/codeforge` subpath:
don't build URLs from the host alone. List endpoints are paginated (`page`,
`limit`, `X-Total-Count` header), so the list wrapper must loop pages.

Thin wrappers:
- `forgejo_repos()` — parsed allowlist
- `forgejo_action_issues()` — all repos, merged, sorted by `updated_at` desc
- `forgejo_issue($repo, $index)`, `forgejo_comments($repo, $index)`
- `forgejo_action_label_id($repo)` — label ids differ per repo; cached 1 day
- `forgejo_render_md($repo, $text)`

### Item identity
An item is identified by `repo` + `index` (the issue number within that repo).
In URLs and the UI, use a key like `repo-a#42`. Store it in the DB as two columns.

### Gotcha: the label filter fails open
Tested on 2026-10-01: `?labels=action` when **no** `action` label exists in the
repo returns **every** issue (75 of 75), not zero. Forgejo silently drops label
names it can't resolve. So:

- `forgejo_action_issues()` must also filter the results in PHP, keeping only
  issues whose `labels[].name` includes `FORGEJO_ACTION_LABEL`. Never trust the
  query parameter alone.
- If `forgejo_action_label_id($repo)` finds no such label, treat the repo as
  having no action items (and log a warning). Don't fall through to the
  unfiltered list.

### Guard: only action items are reachable
Every endpoint that takes `repo` + `index` must call
`forgejo_assert_action_item($repo, $index)` first:

1. `$repo` is in `FORGEJO_REPOS` (exact match; never pass a user-supplied path through)
2. `$index` is a positive int
3. The issue exists, is an issue (not a PR), and has the `action` label

Without this check, anyone with the permission could read or comment on any issue
the service token can see just by changing an id. This is the most important rule
in the design.

### Endpoints: `backend/`
All endpoints are gated by `authorize_endpoint()` (i.e. **not** added to
`endpoint_is_public()`) and also check the `Connect.ActionItems` perm. The ones
that change data also rely on the existing `csrf_origin_ok()` check.

| File                          | Does                                                          |
|-------------------------------|---------------------------------------------------------------|
| `actionItemsList.php`         | Open items + closed in last 14 days, with per-user unread flags |
| `actionItemsGet.php`          | One item + comments (rendered); marks it viewed               |
| `actionItemsCreate.php`       | New issue in a chosen repo, `action` label, optional images   |
| `actionItemsComment.php`      | Add comment, optional images; marks it viewed                 |
| `actionItemsAttachment.php`   | Image proxy (see below)                                       |

### Page: `pages/action-items.php`
`check_auth_redirect(["Connect.ActionItems"])`. Add the route in
`code/route.inc.php` and a menu entry in `code/layout.inc.php` alongside the
others. Use Bootstrap 5 + jQuery like the rest of the app, and keep it minimal:

- **List view:** one row/card per item with:
  - title
  - project name (a friendly name per repo, e.g. from a map in config)
  - Open/Closed badge
  - last updated ("2 days ago")
  - comment count
  - an image thumbnail if the item has one

  Items with new activity show a **"New" / "3 new" badge** and bold title, and
  sort to the top. Filters: Open / Closed / All, and Project. There's a prominent
  "New action item" button.
- **Detail view** (`/action-items?item=repo-a%2342`):
  - title and description
  - the comment thread, oldest to newest, with author, time and inline images
  - comments newer than her last view get a "new" marker, and the page scrolls
    to the first one
  - a comment box at the bottom with "Add photo" (multiple allowed; on a phone
    this opens the camera)
- **Create form:**
  - project (dropdown from the allowlist; hidden if there's only one)
  - title, description, photos
  - nothing else; the `action` label is added automatically

Hide Forgejo details: show the issue number small (for talking to devs) rather
than up front. No markdown help, reactions or timeline events.

### Labels
Status-style labels (e.g. `waiting-on-customer`) are managed in Forgejo and shown
in Connect:
- **Shown:** every label except `action` itself, as a badge in Forgejo's color,
  on list rows and under the item title. The text is black or white depending on
  the background. The label description is the hover text.
- **Filter:** a "All labels" dropdown on the list, built from the labels on the
  current items and combined with the Unread/Open/Closed and project filters. If
  the selected label disappears on a refresh, it stays selected instead of
  silently resetting.
- **Matching:** filtering is by label *name*, so with several repos, a label
  with the same name in each repo filters as one.
- **Unread:** a label change bumps `updated_at` but adds no comment, so it
  doesn't count as unread.

### Rendering content
Issue and comment bodies are markdown. Render them through Forgejo's own
`POST /markdown`, with `Context` set to the repo so `#12` links and relative image
paths resolve. Forgejo sanitizes its output, but we still post-process it:

- Rewrite any `src`/`href` pointing at `FORGEJO_URL/.../attachments/{uuid}` to
  our proxy: `/do/actionItemsAttachment?repo=...&uuid=...`
- Make other links into Forgejo open in a new tab
- Strip our own attribution prefix (see below) from bodies before display
- Cache rendered HTML keyed by `sha1(repo . body)` in `cache/forgejo/`

### Images
**The proxy is required.** The Forgejo instance is private, so attachment URLs
need a Forgejo login, so `<img src="https://www-s01.apinet.com/codeforge/...">` would be broken
for her. `actionItemsAttachment.php`:

- Checks the uuid format and that the uuid belongs to an action item in an
  allowlisted repo (look it up in the issue/comment `assets` lists, cached)
- Fetches the bytes with the service token and streams them back with the
  original `Content-Type` and `Cache-Control: private, max-age=86400`
- Only serves images inline (`image/png|jpeg|gif|webp`); anything else is sent
  as a download

**Uploads** (`forgejo_prepare_uploads()` / `forgejo_normalize_image()`):
- At most 10 photos per post, 15 MB each. Type is checked from the bytes with
  `finfo`; JPEG, PNG, WebP, GIF and HEIC are accepted, and everything else
  (including SVG and PDF) is rejected.
- Every image except GIF goes through Imagick: rotated upright from EXIF, long
  edge capped at 2000px, **metadata stripped (removes phone GPS location)**, and
  HEIC converted to JPEG. GIFs pass through untouched so animation survives.
- Uploaded names are `photo-YYYYmmdd-His-N.ext`; the client's filename is never used.
- All photos are validated before anything is posted, so a bad file never leaves
  a half-made comment.

Flow for a comment with photos:
1. `POST .../comments` with the attributed text → comment id
2. `POST .../comments/{id}/assets` for each photo

The same applies to a new issue (`POST .../issues` with `labels: [<id>]`, then
`.../issues/{index}/assets`).

**Changed from the original plan:** we don't `PATCH` the body to embed the images.
Forgejo already lists uploaded assets under the comment, and our view renders
them (`forgejo_render_assets()`). That saves an API call per post and avoids a
half-updated body if the patch fails. If an upload fails after the text was
posted, the user gets a warning ("posted, but N photo(s) could not be attached").

### Identity / attribution
Every API call runs as **`connect-bot`** (`FORGEJO_BOT_LOGIN`). Each body we
write starts with a fixed header:

```
**Jane Doe** _(via Connect)_
<!-- connect:user=123 -->

<her text>
```

- The name is `users.agent_name` from the Connect DB, falling back to the SSO
  first and last name, then `User#123`. It's stripped of markdown/HTML
  characters and newlines so the header always parses back.
- Developers in Forgejo see who wrote it. Our UI (`forgejo_parse_attribution()`)
  shows the name as the author and hides the header.
- The header is **only trusted when it's at the very start of a body authored by
  `connect-bot`**. A developer can't post as a Connect user, and a user's own text
  (which comes after the header) can't override it.
- The `connect:user` id is what unread tracking uses to skip your own comments.

Later upgrade, if developers find the bot confusing: Forgejo `sudo` (an admin
token acting as a mapped Forgejo user).

## Last-viewed / unread tracking
Track, for each Connect user, when they last looked at each item. Then flag
items that have activity from *someone else* since then.

### Table
Added to `docs/schema.sql` (phpMyAdmin dump style: table, then indexes, then
AUTO_INCREMENT). Standalone migration:
```sql
CREATE TABLE `action_item_views` (
  `pk_action_item_view` int(11) NOT NULL AUTO_INCREMENT,
  `fk_user` int(11) NOT NULL DEFAULT 0,
  `repo` varchar(190) NOT NULL DEFAULT '',          -- "owner/name"
  `issue_index` int(11) NOT NULL DEFAULT 0,
  `seen_through` datetime NOT NULL DEFAULT '0000-00-00 00:00:00',  -- UTC, Forgejo time
  `last_viewed_at` timestamp NOT NULL DEFAULT current_timestamp(),
  `created_at` timestamp NOT NULL DEFAULT current_timestamp(),
  PRIMARY KEY (`pk_action_item_view`),
  UNIQUE KEY `user_item` (`fk_user`, `repo`, `issue_index`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_520_ci;
```

### `seen_through` vs. `last_viewed_at`
The comparison uses **`seen_through`**: the newest Forgejo activity time the user
was actually shown (issue creation, the newest comment on the page, and
`closed_at` if closed), converted to UTC. `last_viewed_at` is only the wall-clock
time of the visit, kept for a future "seen by" feature.

Comparing Forgejo times with Forgejo times avoids two problems:
- **Clock skew** between the Connect and Forgejo servers.
- **The 60 s cache.** A comment posted after the cached page was fetched has a
  `created_at` newer than anything shown, so it still counts as new.

`seen_through` never moves backwards. A visit that serves an older cached copy
can't mark something unread again.

### When "viewed" is recorded
- `actionItemsGet.php` (opening the detail view) upserts the row *after* the
  page has rendered, with `seen_through` = the newest activity on that page.
- Phase 3: `actionItemsComment.php` / `actionItemsCreate.php` will do the same.
  Own comments are already excluded through the `connect:user=` marker either way.

### What counts as "new"
For a given user and item:

- **First visit to the dashboard** (the user has no rows at all): every current
  item is seeded as seen through its `updated_at`. No wall of "New".
- **Never viewed** (an item created after their first visit): "New" badge.
- **Viewed before:** count comments with `created_at > seen_through` that don't
  carry this user's `<!-- connect:user=ID -->` marker. Developers' comments and
  other Connect users' comments count.
- **Closed since seen:** if `closed_at > seen_through`, the badge reads "Just
  closed".

Body edits, label changes and reopening don't count as unread.

### Computing it cheaply in the list
Implemented in `action_items_unread()`:

1. Get the action items (already needed for the list) and the user's view rows
   (one query).
2. Skip items with `updated_at <= seen_through`; nothing can be new there.
3. For the rest, take the oldest `seen_through` per repo and call
   `GET /repos/{repo}/issues/comments?since=…` once (paginated). Then group by
   issue number and count the comments that are new.

`since` is rounded down to the minute so users share the 60 s cache entry. If
that call fails, the list still loads, just without "N new" badges.

### UI
- **List:** unread items sort first with a bold title and a "New" / "N new" /
  "Just closed" badge. There's an **Unread** filter button with a count.
- **Detail:**
  - comments newer than the previous visit get a blue border and a "New" tag
  - the page scrolls to the first new comment
  - a never-viewed item shows a "New" badge in its header instead of marking
    every comment

### Optional: summary for managers
The same table can answer "who has seen this?". The detail view could show "Seen
by Jane 2h ago". That's cheap to add later; it's not in the first version.

## Caching / freshness
- Item list per repo: 60 s in `cache/forgejo/`; cleared whenever we write to that repo
- Repo-wide comments-since: 60 s, keyed by repo + `since` rounded to the minute
- Label ids: 1 day
- Rendered markdown: keyed by content hash, indefinitely (prune old files)
- Optional later: a Forgejo **webhook** (issue + comment events) →
  `backend/forgejoWebhook.php` to clear the cache so changes appear instantly
  instead of within 60 s. It would be a public endpoint, verified with the HMAC
  secret (`X-Forgejo-Signature`). No user notifications are planned.

## Phases
1. **Read-only.** Config, helper, guard, list + detail pages, markdown rendering,
   image proxy. Proves the API, the `/codeforge` subpath and the permissions end
   to end.
2. **Unread tracking.** `action_item_views` table, mark-viewed on open, "New"
   badges and sorting, first-launch seeding.
3. **Write.** Comment, create (with project picker), photo uploads, attribution
   marker + parsing.
4. **Polish.** Mobile layout pass, empty and error states
   (Forgejo down → friendly message, not a blank page).
5. **Optional.** Cache-busting webhook, "seen by", `sudo` attribution.

## Security checklist
- [ ] Token only in `.env`, never sent to the browser or logged
- [ ] Service account limited to the allowlisted repos, with the minimum scopes
- [ ] Repo allowlist; every per-issue endpoint checks for the `action` label
- [ ] Endpoints gated by `authorize_endpoint()` + `Connect.ActionItems` perm
- [ ] CSRF origin check on all writes
- [ ] Rendered HTML comes from Forgejo's sanitizer; we only rewrite URLs. Titles,
      author names and repo names are escaped (`htmlspecialchars` / `.text()`)
- [ ] The attribution marker comes from the session user, never from request input
- [ ] Uploads: images only (checked with `finfo`), size cap, client filenames not trusted
- [ ] Proxy only serves attachments that belong to allowlisted action items

## Setup status
- [x] Forgejo URL, repo (`apiintl/hhmanager`) and token in `.env` / `.env.prod`
- [x] `connect-bot` token verified: can read the repo, labels and issues (push access)
- [x] `action` label created in `apiintl/hhmanager` (id 33, 2026-10-01)
- [x] Instance requires sign-in: anonymous API calls get 403 and attachment URLs
      redirect to `/user/login`. The bot token is accepted on `/attachments/{uuid}`.
- [ ] Add `Connect.ActionItems` to `user_perms` and assign it to users
- [x] Phase 2 (unread tracking) built: `action_item_views` table in `docs/schema.sql`,
      `action_items_unread()` + `action_item_mark_viewed()` in `functions_forgejo.inc.php`
- [ ] Create `action_item_views` in the database (migration SQL above)
- [x] Phase 3 (write) built: `backend/actionItemsComment.php`, `backend/actionItemsCreate.php`,
      comment box + "New action item" form. Verified live on #76 (comment + photo
      upload + attribution + proxy download), then the test comment was deleted.
- [x] From phase 4, done early: photo downscaling, EXIF rotation, GPS strip, HEIC → JPEG
- [x] Phase 4 (polish) built:
      - **Forgejo outage:** if Forgejo doesn't respond or returns a 5xx, the
        list, item and comments are served from the last saved copy, with a
        banner saying when it was saved. A real 404 still says "not found". A
        background refresh that fails keeps the list on screen; it never pops
        an alert.
      - **Errors:** a first-load failure shows an inline "Try again". An item
        that was removed or unlabeled says so. An upload over `post_max_size`
        gets a clear message instead of "not found". Photo count, size and
        type are checked in the browser before uploading.
      - **Empty states:** a message for each filter, and "Create the first
        one" when there are no items at all.
      - **Images:** a broken image becomes an "Image unavailable" placeholder.
      - **Drafts:** a warning before leaving with unsent text or photos. The
        comment draft is kept in localStorage across reloads.
      - **Refresh:** paused while the tab is hidden, and runs immediately when
        it comes back.
      - **Mobile:**
        - full-width New button
        - filter buttons scroll sideways instead of overflowing
        - the New item form opens fullscreen on phones
        - attachments show two per row
        - wide tables scroll, and long URLs wrap
- [x] Labels: badges on list + item view, label filter on the list
- [ ] Visual check of phase 4 on a phone (not done: no local server was running)
- [x] Phase 1 (read-only) built: `code/functions_forgejo.inc.php`,
      `backend/actionItems{List,Get,Attachment}.php`, `pages/action-items.php`,
      route `/action-items`, menu entry
