Commit graph

107 commits

Author SHA1 Message Date
8b55a10c02 fix(agent-api): reject placeholder/weak AGENT_API_TOKEN as unconfigured
.env.example ships AGENT_API_TOKEN=replace-me, and unlike PB_SUPERUSER_PASSWORD's
own replace-me placeholder, an unchanged agent token fails open: any caller
sending "Bearer replace-me" would get full owner-equivalent access. Treat a
secret that is unset, literally "replace-me", or shorter than 32 characters
as not configured, matching the existing 500 behavior for an unset token.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-26 10:23:07 +02:00
ff23890809 feat(agent-api): bearer token check and owner-resolution helper
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-09-25 22:27:46 +02:00
3b869d6e48 ✨ feat(intake): Add area-aware request flow 2026-09-23 09:17:19 +02:00
d011ac6214 ✨ feat(board): Add client moves and review feedback 2026-09-23 08:54:01 +02:00
feb3aa7571 Improve project navigation and admin editing 2026-09-22 23:22:38 +02:00
b52d4a6287 Enhance intake link functionality and theme management
- Added `NEXT_PUBLIC_APP_URL` to `.env.example` for app URL configuration.
- Updated `README.md` to document the new `NEXT_PUBLIC_APP_URL` variable.
- Improved CSS to support light and dark color schemes.
- Refactored intake link actions to utilize the new app URL and handle token generation more efficiently.
- Enhanced public intake route to validate work area selection.
- Updated `IntakeLinksPanel` to allow copying of generated links.
- Improved error handling and user experience in `BriefPreviewPublic` and `IntakePublic` components.
- Refined theme synchronization and toggle components for better user preference management.
2026-09-22 22:24:14 +02:00
d9f6ac0fad Fix owner card creation number allocation 2026-09-22 21:59:15 +02:00
20639264bd Add project work areas to Kanban and Plan 2026-09-22 21:46:35 +02:00
8452c9d5e7 Show booked days in plan date picker 2026-09-22 21:11:58 +02:00
78fc06555d feat(plan): show work item details and wrap titles 2026-09-22 20:55:22 +02:00
ab81f32cdf perf(board): batch signed-in project summary reads 2026-09-22 20:39:55 +02:00
9778ee09d4 feat(plan): order timeline by dates and priority 2026-09-22 08:35:17 +02:00
a6785f74ee chore(plan): add targeted hosted schema command 2026-09-22 08:30:20 +02:00
48458f495f feat(plan): rebuild collaborative timeline planner 2026-09-22 08:28:41 +02:00
2b01d0e837 feat(plan): bar-math helpers + open reschedule to anyone signed in
Pure helpers for the Gantt's drag/double-click math:

- fractionToDate(fraction, range): what ISO date a fractional
  position in the chart represents, snapped to whole UTC days.
- shiftDatesByFraction: move a bar's start and target by the
  same delta, clamped to the visible range, with start dropped
  when clamping would invert or collapse the span.
- resizeEndToFraction: what the new target date becomes when
  the user drags a right edge.
- moveCardByFraction: alias that produces the action's input
  shape.

11 unit tests cover the corners (no dates set, point bar,
clamping past the right edge, inversion guard).

rescheduleCardAction also drops its owner-only gate. Any
signed-in user — owner or client manager — can move dates
now. PocketBase's existing row-level rules govern which cards
a given user can write (a client manager still only writes
their own client's cards); the activity-log row is unchanged
and attributes the move to the actual user either way.

Adds setSubtaskDatesAction so the same drag/double-click
gestures work on subtask rows. No audit log on subtasks —
matches the existing pattern (subtask toggles don't log).
2026-09-22 05:16:04 +02:00
92c2501726 feat(plan): loadPlan loader with pure buildPlanSections helper
New loader returns the sectioned shape the Gantt view needs:

- buildPlanSections (pure): groups cards by project, sorts by
  start_date asc / title, splits subtasks into dated vs undated.
  Five unit tests cover the grouping and sorting rules.
- loadPlan (server-only): one PocketBase trip per collection,
  delegates to buildPlanSections.

471/471 unit tests pass (was 466, +5 new).
2026-09-21 16:13:47 +02:00
a5dc6083dd feat(schema): add cards.assignee and subtask date fields
Three new optional fields, idempotent ensure helpers:

- cards.assignee: free-text label rendered above the card bar
  on the new /plan Gantt. Empty by default.
- subtasks.start_date: ISO date or empty. Lets subtasks appear
  on the Gantt under their parent card with their own duration.
- subtasks.target_date: same, for the bar end.

Live migration confirmed idempotent — first run added the
fields, second run reported both already present.
2026-09-21 16:12:29 +02:00
cab3b98372 chore(plan): pick up Plan Puzzle v1 in-progress work and Forgejo repo-url helper
The branch plan-puzzle-v1-tests had accumulated changes from a
sibling thread (Plan Puzzle v1 backend + frontend) that hadn't
been committed yet, plus a new src/lib/git/repo-url helper used by
the Forgejo ingest path. Pick them all up so the working tree is
clean and the branch reflects everything since its fork.

Files (Plan Puzzle):
  src/app/actions/client-proposal.ts
  src/app/actions/owner-plan.ts
  src/app/actions/reschedule.ts
  src/app/actions/reschedule-helpers.ts (+ test)
  src/components/admin/project-form.tsx
  src/components/roadmap/roadmap-builder.tsx
  src/components/roadmap/timeline-view.tsx
  src/lib/cards/write-with-audit.ts (+ test)
  src/lib/roadmap/timeline.ts (+ test)

New helper:
  src/lib/git/repo-url.ts (+ test)

Verified: npm run build clean, 466/466 unit tests pass.
2026-09-21 14:40:04 +02:00
e4779c76e0 test(intake): picker integration tests + final cap wiring
Phase 9c. 12 tests covering:
- picker page (multi-project list, single-project redirect,
  zero-projects fallback)
- wizard URL (project-on-client 200, cross-client 404,
  show_in_intake=false 404)
- submission flow (card lands on chosen project's inbox,
  cross-client refused, hostile body fields ignored,
  per-(link, project) cap isolates one project from another)
- Phase 8 backwards compat (per-project link redirects to the
  wizard; the existing Phase 8 submission route still accepts
  Phase 8 links)

Three bridges from the plan:
1. PocketBase stores date as 'YYYY-MM-DD 00:00:00.000Z'; the
   filter must use that form. Passing 'YYYY-MM-DD' silently
   matches zero rows, even though the create succeeds.
2. PocketBase's getFirstListItem returns 404 for a row just
   written by the same admin client; getList works. The helper
   uses getList with perPage=1.
3. PocketBase's read-after-write latency is significant.
   isLinkProjectUsable retries with exponential backoff for
   up to ~2.5s before giving up; bumpDailyCapForProject's
   upsert pattern uses a fallback read+update path if the
   create hits the composite unique index from a peer's
   concurrent write.
2026-09-21 14:09:36 +02:00
78c3b9e401 feat(intake-links): picker project list and resolver
Phase 9b Task 3. listPickerProjects reads the public-eligible
projects on a client; resolveProjectForClient checks that a given
projectId belongs to the client and is opted in. Both fail closed
on any DB read error.
2026-09-21 13:31:48 +02:00
a2986a11d0 test(intake): per-(link, project) cap infrastructure
Phase 9a Task 6. Three integration tests:
  - schema lands: row inserts with link/project/date/count fields,
    composite unique index is the only constraint (different
    project same link + date is allowed)
  - duplicate (link, project, date) is rejected by the unique index
  - the public route returns a sensible status, never 500, on a
    Phase 9 link (the route will be wired in 9b; 9a only proves
    it does not crash today)

Two bridges from the plan:
  - PocketBase's date field defaults to unique=true; my schema
    fix flipped it via ensureIntakeLinkSubmissionsDateNotUnique.
    The relation and number fields appeared to share the same
    default but getOne()'s response omits the key, so the
    explicit unique=false on those doesn't stick — yet rows with
    the same link and different projects work in practice (the
    composite index does its job). Worth a follow-up if a future
    PocketBase release tightens the default.
  - bumpDailyCapForProject wraps the read-then-create-or-update
    in a retry on the unique constraint to handle PocketBase's
    read-after-write latency window. The cap-isolation property
    is verified end-to-end in 9c.
2026-09-21 13:27:58 +02:00
b824f02f02 feat(intake-links): per-(link, project) cap read/write
Phase 9a Task 3. isLinkProjectUsable reads today's counter (or
treats no row as 0). bumpDailyCapForProject upserts via the unique
(link, project, date) index. The Phase 8 helpers stay live for the
old shape; Phase 9b's wizard picks which one to call based on
whether the link is per-project (Phase 8) or per-client (Phase 9).
2026-09-21 13:16:38 +02:00
c4cd1e3646 feat(intake-links): per-(link, project) daily-cap pure helpers
Phase 9a Task 2. Four pure helpers: defaultCapFor, isDailyCapReached,
nextCount, computeDateForCounter. 7 unit tests cover the caps
boundary (cap=0 means 'no submissions today'; todayCount === cap
is reached), the count increment, and the local-date format.

Note: project convention puts unit tests next to source
(src/**/*.test.ts), not under tests/unit/. Adjusted from the plan.
2026-09-21 13:15:56 +02:00
eb45fb0168 feat(schema): intake_links.client, projects.show_in_intake, intake_link_submissions
Phase 9a Task 1. Additive only — the Phase 8 shape stays live.
intake_links.project becomes optional so Phase 9 rows can leave it
empty. intake_links.client is the new owner of the link, read by
the picker page in 9b. projects.show_in_intake defaults to false
and is the knob that hides a project from the picker. The new
intake_link_submissions collection tracks the daily counter per
(link, project, date) so the cap applies per project, not per link.

Type additions:
  - ProjectRecord.show_in_intake: boolean
  - IntakeLinkRecord.client: string (Phase 9)
  - new IntakeLinkSubmissionRecord
2026-09-21 13:15:01 +02:00
5c6f82cb74 test(intake): background markdown prompt tests and truncation
Phase 8c Task 4. Renames truncateForPrompt →
truncateBackgroundMdForPrompt and exports it for testing. Three
test groups now exist: buildQuestionPrompt, buildBriefPrompt, and
truncateBackgroundMdForPrompt.

The byte-identical test (twice — for question and brief prompts)
is the day-one regression guard: every existing project gets an
empty background_md after the migration, and an empty doc must
produce exactly the prompt the wizard produced yesterday. A diff
here breaks every in-flight interview on the day 8c ships.
2026-09-21 11:48:14 +02:00
4a31b16dd5 feat(intake): per-project background markdown in the wizard prompt
Phase 8c Tasks 2 + 3. IntakeContext gains backgroundMd; intakeContext
in interview.ts reads project.background_md, trims it to the
30000-char prompt cap with a marker. Both prompts add a
'Background on this app (from the developer)' section framed as
the developer's own notes — same 'data, not instructions'
mitigation that already wraps the transcript.
2026-09-21 11:47:22 +02:00
7df7074806 feat(schema): projects.background_md for per-project context
Phase 8c Task 1. Optional text field on projects, max 40000 chars.
The prompt module caps at 30000 with a truncation marker. Empty
documents produce byte-identical prompts to today — the test in
Task 4 locks that in.
2026-09-21 11:44:58 +02:00
3ece6cb956 feat(intake-links): token resolver and daily-cap helpers
Phase 8a Task 5. resolveIntakeLink returns null for unknown tokens
(spec §3: caller gives the same response as for a revoked token).
isLinkUsable lazily resets today's counter on date rollover.
validateIntakeLinkForStep returns a three-valued LinkState so the
route can distinguish 'inactive/expired' from 'daily-cap'.
2026-09-21 01:59:28 +02:00
3b8ffb8e1f feat(intake-links): per-(token,ip) rate limiter
Phase 8a Task 4. 60 requests per 5-minute window per opaque key.
In-memory, lossy on restart, no IP persistence. Backed by the
daily cap on intake_links for coarse-grained abuse control.
2026-09-21 01:58:38 +02:00
f608931779 feat(http): best-effort client IP from headers
Phase 8a Task 3. Reads x-forwarded-for (first hop), then x-real-ip,
falls back to 'unknown'. Not used for auth or logging — pure rate-
limit keying so concurrent clients do not share one bucket.
2026-09-21 01:58:06 +02:00
36572d99f3 feat(intake): token generator and shape check
Phase 8a Task 2. 24 bytes of CSPRNG entropy, base64url-encoded to
exactly 32 URL-safe characters. isIntakeToken matches that exact
shape so the public route can fail fast on bogus input without
hitting the database or charging the rate limiter.
2026-09-21 01:57:38 +02:00
c99be4d49e feat(schema): intake_links and anonymous submitter role
Phase 8a Task 1. Adds the new intake_links collection (per-token
public entry point, owner-only rules), extends cards.created_by_role
with 'anonymous', and extends activity.action with
'card_submitted_anonymously'.

Note: getOrCreateCollection is a no-op on existing collections, so
the two existing-value extensions are applied by new/extended
ensure functions (ensureCardsCreatedByRoleValues and
ensureActivityActionValues respectively). Re-runs are idempotent.
2026-09-21 01:54:11 +02:00
29013b5276 Merge branch 'plan-puzzle-v1-backend' into plan-puzzle-v1-frontend 2026-09-19 18:55:00 +02:00
b3aac9ba4f feat(plan): Plan Puzzle v1 backend — proposal fields, server actions, Done-column guards
Adds the three proposal fields (proposed_target_date, proposed_by,
proposed_at) to the cards collection with a partial index that backs
the 'pending proposals' query, plus two new ActivityAction values
(date_proposed, date_proposal_resolved) and column_changed for
return-to-scheduled.

New server actions under src/app/actions/:
  - client-proposal.ts: client-manager drag creates a proposal (not
    a target_date write); 404 (not 403) on cross-tenant failures and
    Done-column cards, matching the rest of /api/client/*.
  - owner-plan.ts: accept and dismiss clear the three proposal
    fields together and emit the audit rows; returnToScheduledAction
    moves a Done card back to the first working column.

Server-side Done-column guards added to:
  - src/app/actions/reschedule.ts (was UI-only)
  - src/app/api/client/cards/[id]/route.ts (was UI-only)

Both pass through the new writeCardWithAudit helper at
src/lib/cards/write-with-audit.ts — single source of truth for
'patch card + emit activity rows', replacing the inline copies in
the PATCH route and rescheduleCardAction.

Adds src/lib/roadmap/slot-date.ts (slot <-> target_date mapping)
consumed by Pixel's puzzle UI; named SlotId API rather than the
plan's numeric indices for self-documenting callers.

CLIENT_EDITABLE_CARD_FIELDS stays at priority + target_date per
the brief; proposals go through the dedicated action so they get
their own audit row type.
2026-09-19 18:35:33 +02:00
Pixel
b2f332436a feat(plan): Puzzle v1 frontend — tray + 3 month slots, keyboard listbox, proposals, toast
The Plan page replaces the read-only Bands tab with a drag-and-drop
puzzle surface. Both roles drag; owners write target_date directly via
the existing rescheduleCardAction, clients write proposed_target_date
via proposePlanMoveAction. Done-column cards are excluded; they live
on the Timeline tab with a new 'Return to scheduled' button.

UI:
- Tray (unscheduled) + 3 slot columns: This Month / Next / Month After.
  Slot headers carry count + '+N more' chip beyond 12 cards. Tray
  virtualises via scroll-window math at 50+ cards.
- HTML5 drag-and-drop matching the existing PendingTray pattern; the
  PuzzleView keeps the optimistic-state pattern from the kanban Board
  (no router.refresh until the server confirms).
- Keyboard parity: Tab focuses a card, Space opens a listbox (3 slot
  options), 1/2/3 picks a slot, Escape closes, ArrowUp/Down reorders
  within the slot (optimistic local display; no v1 server action for
  in-slot position). aria-live='polite' announces each successful move.
- prefers-reduced-motion via Tailwind motion-safe: variants — the 180ms
  settle animation is dropped for users who request it.

Proposal overlay:
- RoadmapCard shows a left-edge accent stripe + Accept/Dismiss chip
  when proposedTargetDate is set and the viewer is the owner.
- The existing RoadmapCard primitive is reused — no new card component.

Return-to-scheduled button:
- New component on Done-card rows in TimelineView. Replaces the
  drag-sideways motion that the new Done-column server guard blocks.
- Owner-only, calls returnToScheduledAction.

Toast system:
- New ui/toast.tsx: useToast() hook + top-right aria-live region, 4s
  auto-dismiss. AppShell mounts the provider so any page can call it.
- roadmap-builder.tsx no longer console.errors on a failed drag.

Server action stubs:
- client-proposal.ts and owner-plan.ts: temporary no-op stubs that
  return a non-ok response. Deleted when Lin's branch lands; the
  dynamic imports in the UI call them lazily so a missing module
  surfaces through the toast rather than crashing.

Type widening:
- src/lib/roadmap/load.ts: adds optional proposedTargetDate,
  proposedBy, proposedAt, isDone fields to the RoadmapCard interface
  so the Puzzle compiles against pre-migration data. Lin's branch
  populates and tightens these.

Files (15 changed):
- New: slot-date.ts + tests (slot↔date mapping)
- New: puzzle-keyboard.ts + tests (pure listbox state machine)
- New: toast.tsx, proposal-overlay.tsx, return-to-scheduled-button.tsx
- Rewritten: band-view.tsx now exports PuzzleView (per task brief)
- Updated: roadmap-tabs, roadmap-card, roadmap-builder, timeline-view,
  plan/page, app-shell, load.ts

Tests (38 new):
- 18 unit tests for slot-date.ts (month boundaries, year rollover,
  past/future, mid-month symmetry)
- 20 unit tests for the keyboard listbox state machine
  (nextListboxState + isCardShortcut)

UX decisions worth flagging:
- ArrowUp/Down reorders are optimistic only — the order resets to
  canonical (priority desc, title asc) on the next page load. No v1
  server action exists for in-slot position; v2 should add one.
- Done cards are excluded from the Puzzle (per spec §6.1); the
  Return-to-scheduled button lives on the Timeline tab (per task brief).
- A drop on the tray itself is a no-op in v1 — there is no
  'unschedule' action, and the spec lists only 1/2/3 for the keyboard
  listbox.
2026-09-19 18:29:32 +02:00
8c860f2d50 fix(auth): verify sessions against PocketBase instead of trusting the cookie
getCurrentUser() only decoded the pb_auth cookie locally and trusted its
embedded role/client/active fields directly — the PocketBase JS SDK holds
no secret to check a JWT signature against, so a completely forged cookie
(garbage signature, role: "owner", any email) was accepted as a real
session. Verified exploitable end-to-end: a raw Cookie header against the
live app, no browser and no password, rendered the owner's home page.

This mattered most for actions writing through the superuser connection
(log-work.ts, summarise.ts, ingest.ts, and critically users.ts for account
creation) — those never route the caller's token through PocketBase's own
rule engine at all, so the app-level role check was the ONLY gate, and it
was reading an unverified claim. Actions using the caller's own token
(cards.ts, projects.ts) still made the wrong authorization DECISION from
the forged role, even though PocketBase's rule engine would have rejected
the actual data write.

Fixed by having getCurrentUser() call authRefresh(), which asks PocketBase
to confirm the token is genuine; a forged or tampered token is refused
there and treated as signed-out. Wrapped in React's cache() so the extra
round-trip happens once per request, not once per call site (several pages
call this more than once). Re-fetching the live record also means a
deactivated account (`active: false`) stops working on its very next
request instead of only being blocked at a new login.

Regression test builds the exact forged cookie used to verify this,
confirmed red against the vulnerable code and green against the fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-19 12:47:18 +02:00
1aaf31a4c5 refactor(ai): extract testable polish prompt, guard against empty input
- Extract prompt text into buildPolishPrompt() in polish-prompt.ts (no server-only),
  matching the pattern used by batch.ts/buildBatchPrompt and narrative.ts/buildNarrativePrompt
  so the prompt itself is unit testable.
- Add MAX_NOTE_LENGTH guard to truncate excessively long notes before polishing.
- Add early-return guard against empty input (non-retryable error).
- Run getProvider() and getSystemPrompt() concurrently via Promise.all().
- Document that polishNote() throws (foreground user-initiated action).

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-09-19 11:02:16 +02:00
3c8edeb946 feat(ai): single-shot note polishing for logged work entries
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-09-19 10:51:49 +02:00
5283a455fa fix(ai): exclude manual entries from card narratives, clarify summary panel wording
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-09-19 10:50:12 +02:00
08cf5d5ce7 fix(ai): never queue a logged entry for AI re-summarization
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-09-19 10:42:30 +02:00
5d772ef672 feat(metrics): recompute reads manual entries, methodology states them
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-09-19 10:34:25 +02:00
47d19f9f38 fix(metrics): kpi caption honesty, manual-entry safety, extra coverage — Task 2 review findings
- Fix KPI row "Hours invested" caption to honestly show both session-derived and manually logged hours
- Add JSDoc warnings about manualMinutes being a discriminator (do not pass 0 for git commits)
- Extract isManual predicate to single source of truth, avoid triple derivation
- Add tests for mixed session+manual in same month and manual-only shape

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-09-19 10:32:40 +02:00
284385ca2b feat(metrics): fold manually logged minutes into project totals
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-09-19 10:25:04 +02:00
4d1e6cf412 fix(commits): set source on ingest/backfill writes, tidy Task 1 review findings
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-09-19 10:22:26 +02:00
65b09170af feat(commits): add source and manual_minutes fields
Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-09-19 10:14:51 +02:00
2cb745779a feat(nav): sidebar Kanban flyout, grouped by client 2026-09-17 10:42:50 +02:00
5dae0946eb feat(projects): add kind field (project | prototype) 2026-09-17 10:42:46 +02:00
6c41f4c662 fix(ai): raise max_tokens — inline reasoning was eating the whole budget
MiniMax M2.x reasons inside <think> tags in the normal content field, and
that reasoning is billed against max_tokens. At 4096 the heavier batches
spent the entire budget thinking, were truncated before writing a single
answer, and stripThinking correctly removed the unclosed block — leaving an
empty response.

The signature was distinctive: batches returned 25/25 or 0/25, never
anything partial. 193 of 779 commits went unsummarised with no error
anywhere. At 16384 the same 193 completed 8/8 batches with zero failures.

The interview's question call had the same bug at 1024, where it would have
returned an empty question and silently ended the interview on the first
turn. Now 8192.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 09:19:34 +02:00
6e231374e2 feat(roadmap): loader scoped by the caller's own read rules 2026-09-17 07:17:18 +02:00
b164f3273c feat(roadmap): checklist-derived completion percentage 2026-09-17 07:16:57 +02:00