From 48b07206ee362efe7f357488ef1ca78bd2509582 Mon Sep 17 00:00:00 2001 From: apeltekci Date: Thu, 28 May 2026 22:51:47 -0700 Subject: [PATCH 01/24] docs: native give-feedback app design (Architecture C) Two-track program: a public end-user API added to OpenCoven/feedback (anonymous reads + better-auth bearer writes, reusing existing services) and a native SwiftUI give-feedback app that consumes it via an OpenAPI-generated client. Captures the audit (v1 API is admin/API-key only; end-user surface is web-rendered), the resolved forks (end-user app, full feature set, web access yes, email-OTP bearer auth, 4-tab nav), and API-first sequencing. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore | 3 + ...6-05-28-native-give-feedback-app-design.md | 213 ++++++++++++++++++ 2 files changed, 216 insertions(+) create mode 100644 docs/superpowers/specs/2026-05-28-native-give-feedback-app-design.md diff --git a/.gitignore b/.gitignore index a12302a..a860e53 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,6 @@ build/ # ── Swift PM ───────────────────────────────────────────────────────────────── .swiftpm/ + +# ── Brainstorm companion artifacts ─────────────────────────────────────────── +.superpowers/ diff --git a/docs/superpowers/specs/2026-05-28-native-give-feedback-app-design.md b/docs/superpowers/specs/2026-05-28-native-give-feedback-app-design.md new file mode 100644 index 0000000..b741cf4 --- /dev/null +++ b/docs/superpowers/specs/2026-05-28-native-give-feedback-app-design.md @@ -0,0 +1,213 @@ +# Native Give-Feedback iOS App + Public End-User API (Architecture C) + +**Date:** 2026-05-28 +**Status:** Design approved (sections), pending spec review +**Author:** Andrew Peltekci (with Claude) +**Related:** `2026-05-28-ios-sdk-conformance-native-design.md` (the embeddable SDK — a separate product) + +## 1. Summary + +Build a **standalone native iOS app for end users to give feedback** to an +OpenCoven Feedback instance: browse boards, vote, comment, submit posts, read +the changelog, and read the help-center docs — with content that **auto-updates** +as the web app changes, because everything is fetched live from an API. + +The audit found OpenCoven exposes a rich, OpenAPI-documented REST API at +`/api/v1/*`, but it is **API-key authenticated for team/admin/integration use** +— unusable from a shipped consumer app. The only thing an anonymous/end-user +client can fetch today is the public widget config and `kb-search`; everything +else an end user reads or writes goes through server-rendered TanStack server +functions (portal) or the embedded widget iframe — neither a public contract a +native app can consume. + +Therefore this is a **two-track program**: + +- **Track 1 — Public End-User API** (in `OpenCoven/feedback`): a thin + `/api/public/v1/*` surface — anonymous reads + end-user (better-auth bearer) + writes — that **reuses the existing domain services**, adding no new business + logic and no schema migrations. +- **Track 2 — Native iOS app** (in `feedback-mobile`): a SwiftUI app that + consumes Track 1 via a client generated from its OpenAPI spec. + +The API contract is the shared linchpin; this single design doc covers both +tracks. Implementation is sequenced API-first (Track 2 is untestable without +Track 1, though it can start against a spec-generated mock). + +## 2. Goals / Non-goals + +### Goals +- End users can browse boards, vote, comment, and submit posts natively. +- End users can read the changelog and help-center articles natively. +- Content auto-updates from the server with no app release (live API reads). +- End-user auth via better-auth **bearer token** (email-OTP primary). +- The iOS client is generated from a published OpenAPI spec, so contract + evolution flows into the app via codegen rather than hand edits. +- The public API is additive to OpenCoven, reusing existing services — small, + reviewable, logic-free, maximizing upstream-PR acceptance odds. + +### Non-goals (v1 — all fast-follows) +- Anonymous writes (v1 requires sign-in for submit/vote/comment). +- Roadmap tab, notifications, push notifications. +- Offline write queue (writes require connectivity). +- Multi-instance support (the app points at one instance). +- Any change to the embeddable `OpenCovenFeedback` SDK (separate product). + +## 3. Audit findings (why Architecture C) + +- `/api/v1/*` is comprehensive and OpenAPI-documented (`/api/v1/openapi.json`, + Swagger UI at `/api/v1/docs`) but every route (incl. reads) calls + `withApiKeyAuth(request, { role: 'team' | 'admin' })` — a workspace API key + (`Bearer qb_…`). Not shippable in a consumer app. +- End-user content (boards/posts/changelog/help/roadmap) is rendered by the + **portal** via TanStack server functions, and interactions go through the + **widget** (`/api/widget/*`: config, session, identify, search, kb-search, + upload) using cookie sessions / host-signed `ssoToken`. No public REST CRUD. +- Auth stack is **better-auth** with the **`bearer` plugin enabled**, plus + email-OTP, magic-link, OAuth, and separate portal (end-user) auth config — so + a native app can authenticate end users with a bearer token. +- Conclusion: the product has every feature; what's missing is a public + end-user API. Adding one (Track 1) unlocks a fully native app (Track 2). + +## 4. Track 1 — Public End-User API (`OpenCoven/feedback`) + +**Principle:** add a thin public surface, reuse all existing logic. Handlers +call the same domain services the portal/admin API already use +(`post.public`, `post.voting`, `comment.service`, `changelog.service`, +`help-center.service`, `getPublicWidgetConfig`). New code = an end-user auth +middleware, thin route handlers, response schemas, and OpenAPI entries. No new +business logic, no migrations. + +### Auth +- Namespace `/api/public/v1/*`, distinct from admin `/api/v1/*` (API-key) and + `/api/widget/*`. +- End-user auth = better-auth **bearer token** (`bearer` plugin already on). + Sign-in via **email-OTP** (primary; no deep link needed) or OAuth. Token + stored client-side, sent as `Authorization: Bearer `. +- **Reads: anonymous allowed.** With a token, responses enrich with personal + state (e.g. `hasVoted`). +- **Writes: require a valid session.** v1 = signed-in only; anonymous + (widget-style throwaway session) writes are a documented fast-follow. +- Reuse the existing per-IP/per-session rate limiter and tenant scoping. +- Auth endpoints: reuse better-auth's existing `/api/auth/*` (email-OTP + request/verify, OAuth) — no new auth endpoints. + +### Endpoints +Reads (anonymous OK): +- `GET /api/public/v1/config` — public config (tabs, theme, defaultBoard) — reuses `getPublicWidgetConfig` +- `GET /api/public/v1/boards` +- `GET /api/public/v1/posts?boardId=&sort=&search=&cursor=&limit=` — feed (`voteCount`, `status`, `hasVoted`) +- `GET /api/public/v1/posts/:id` +- `GET /api/public/v1/posts/:id/comments?cursor=` +- `GET /api/public/v1/changelog?cursor=` · `GET /api/public/v1/changelog/:id` +- `GET /api/public/v1/help/categories` · `GET /api/public/v1/help/articles/:slug` · `GET /api/public/v1/help/search?q=` (reuses `kb-search`) + +Writes (bearer session required): +- `POST /api/public/v1/posts` — `{ boardId, title, content }` +- `POST /api/public/v1/posts/:id/vote` — toggle vote +- `POST /api/public/v1/posts/:id/comments` — `{ content, parentId? }` + +### Contract delivery +- Publish `/api/public/v1/openapi.json` (extend the existing OpenAPI generator + or a parallel public spec). This is the single source of truth the iOS client + is generated from. + +### Error model +- Mirror the existing `{ error: { code, message } }` shape used by the widget + and v1 responses. Standard codes: `UNAUTHORIZED`, `VALIDATION_ERROR`, + `NOT_FOUND`, `RATE_LIMITED`, `WIDGET_DISABLED`/`DISABLED`. + +## 5. Track 2 — Native iOS app (`feedback-mobile`) + +**Navigation:** 4-tab bar — Feedback · Changelog · Help · Account. + +**Relationship to the SDK:** the app does native reads + writes against the +public API and does **not** use the widget WebView. The `OpenCovenFeedback` SDK +remains a separate product (for third parties embedding the widget). The app +reuses the `FeedbackApp` demo *shell* — `AppConfiguration`'s instance-URL +plumbing and design tokens — replacing the single `HomeView` with the tabs. + +**Modules** (each a SwiftUI view + observable model): +- **API client** — generated from `/api/public/v1/openapi.json` via + **swift-openapi-generator**. Base URL = configured instance URL. Regenerating + from the published spec is how contract evolution enters the app. +- **Auth** — `AuthStore` holding a better-auth bearer token in the **Keychain**. + Email-OTP flow (email → code → token). Anonymous browsing by default; the + first write triggers a sign-in sheet, then the action retries. +- **Feedback** — boards + feed (sort/filter), post detail (comments + vote), + compose/submit. +- **Changelog** — list + detail (read-only). +- **Help** — categories → articles + search (read-only). +- **Account** — sign-in/out, profile. +- **Shared** — design system, OpenAPI-generated models, instance-URL config, + loading/empty/error states. + +**Data flow:** View ⇄ observable model ⇄ generated client ⇄ public API. Reads +anonymous (enriched with `hasVoted` when signed in); writes attach the bearer +token; a `401` opens the sign-in sheet then retries the action. + +**Offline/caching (light):** `URLCache` + a small on-disk cache of the last +feed/changelog/help so the app opens to content offline (read-only). Writes +need connectivity; no offline write queue in v1. + +**Config/scope:** single-instance, configured by instance URL (like the SDK). + +## 6. Sequencing + +**API-first, two tracks:** + +- **Track 1 (web) — build/PR first.** + 1. End-user auth middleware (`optionalSession` / `requireSession`, bearer). + 2. Read endpoints (config, boards, posts, comments, changelog, help). + 3. Write endpoints (submit, vote, comment). + 4. `/api/public/v1/openapi.json`. + 5. Tests (below). + +- **Track 2 (iOS) — start against a spec-generated mock, then the real API.** + 1. Client codegen + instance-URL config + email-OTP auth (`AuthStore`, Keychain). + 2. Feedback tab (feed, detail, vote, comment, submit). + 3. Changelog tab. + 4. Help tab. + 5. Account tab. + 6. Polish: offline cache, accessibility, error/empty states. + +Each track ships as its own implementation plan (writing-plans), sharing this +design and the OpenAPI contract. + +## 7. Testing + +- **Web (Track 1):** per-endpoint integration tests (reuse existing patterns), + auth tests for anonymous vs session paths, and an OpenAPI contract check. +- **iOS (Track 2):** view-model unit tests against the generated mock client, + snapshot tests for key screens (feed, post detail, submit, sign-in), and one + integration smoke against a live/staging instance. + +## 8. Risks & mitigations + +- **Upstream PR acceptance (biggest).** Track 1 is a PR to a repo we don't own. + Mitigation: additive, logic-free, mirrors existing route/auth/schema patterns. + Fallback: run the public API as a thin separate service over the same DB/API + if upstream declines. +- **Email-OTP abuse.** Reuse the existing `signin-rate-limit`. +- **Cross-repo contract drift.** OpenAPI is the single source; iOS client is + codegen'd; add a CI check that the committed spec matches the routes. +- **App Store wrapper rejection.** Low — this is a genuinely native app, not a + WebView wrapper. + +## 9. Decisions log (forks resolved during design) + +- App audience: **end-user give-feedback app** (not admin/triage). +- Feature set: submit, browse + vote + comment, changelog, help-center. +- Web-repo access: **yes** — we can add a public API (Architecture C). +- Auth: better-auth **bearer**, **email-OTP** primary; **signed-in writes only** + in v1 (anonymous writes deferred). +- Navigation: **4-tab bar** (Feedback · Changelog · Help · Account). +- The standalone app does **not** depend on the embeddable SDK. + +## 10. Definition of done + +- Track 1: `/api/public/v1/*` reads (anonymous) and writes (bearer session) pass + integration + auth tests; `/api/public/v1/openapi.json` is published; PR open + upstream. +- Track 2: the app builds in CI; a signed-in user can browse a board, vote, + comment, submit a post, read the changelog, and read a help article against a + live instance; content reflects server-side changes with no app release. From 8b48421267cc2ec649415403f23324dda2e91210 Mon Sep 17 00:00:00 2001 From: apeltekci Date: Thu, 28 May 2026 23:08:29 -0700 Subject: [PATCH 02/24] docs: implementation plans for native give-feedback app (Tracks 1 & 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Track 1 (OpenCoven/feedback): 12 TDD tasks adding /api/public/v1 — portal-session bearer auth, anonymous reads, authed writes, public OpenAPI — reusing existing domain services. Track 2 (feedback-mobile): 14 TDD tasks for FeedbackKit (typed client, AuthStore, view models, cache) + a SwiftUI 4-tab FeedbackPortalApp, with view models tested against a mock so the app can be built before the live API exists. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../plans/2026-05-28-track1-public-api.md | 1268 ++++++++++++++ .../plans/2026-05-28-track2-native-ios-app.md | 1501 +++++++++++++++++ 2 files changed, 2769 insertions(+) create mode 100644 docs/superpowers/plans/2026-05-28-track1-public-api.md create mode 100644 docs/superpowers/plans/2026-05-28-track2-native-ios-app.md diff --git a/docs/superpowers/plans/2026-05-28-track1-public-api.md b/docs/superpowers/plans/2026-05-28-track1-public-api.md new file mode 100644 index 0000000..e8a68c9 --- /dev/null +++ b/docs/superpowers/plans/2026-05-28-track1-public-api.md @@ -0,0 +1,1268 @@ +# Track 1 — Public End-User API Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a public `/api/public/v1/*` REST surface to `OpenCoven/feedback` — anonymous reads + end-user (better-auth bearer) writes — so a native app can browse boards/posts, vote, comment, submit, and read changelog + help, reusing existing domain services. + +**Architecture:** New routes under `apps/web/src/routes/api/public/v1/`, structurally cloning the admin `/api/v1/*` routes but swapping `withApiKeyAuth` for a portal-session helper (`optionalPortalSession` / `requirePortalSession`) that reuses the existing bearer→session→principal lookup from `getWidgetSession`. Writes are attributed to the session's principal. One genuinely new service query (`listPublicPosts`) powers the anonymous feed; everything else calls existing services. A parallel OpenAPI document is published at `/api/public/v1/openapi.json`. + +**Tech Stack:** TanStack Start (`createFileRoute` server handlers), Zod, better-auth (`bearer` plugin + `session` table), Drizzle, `zod-openapi`, Vitest. + +**Repo:** This plan executes in `OpenCoven/feedback` (clone it; this is NOT the `feedback-mobile` repo). All paths below are relative to that repo root. + +--- + +## File Structure + +- Create `apps/web/src/lib/server/domains/api/portal-auth.ts` — `optionalPortalSession()` / `requirePortalSession()`. Owns end-user (portal) bearer auth for public routes. Wraps the existing session-by-token lookup. +- Create `apps/web/src/lib/server/domains/posts/post.public-list.ts` — `listPublicPosts(...)`, the anonymous-safe feed query (public boards, visible posts only). Kept separate from `post.inbox.ts` (admin) so admin/public visibility rules never tangle. +- Create read routes: `apps/web/src/routes/api/public/v1/{config,boards/index,posts/index,posts/$postId,posts/$postId.comments,changelog/index,changelog/$entryId,help/categories/index,help/articles/$slug,help/search}.ts` +- Create write routes: `apps/web/src/routes/api/public/v1/posts/$postId.vote.ts`, `apps/web/src/routes/api/public/v1/posts/$postId.comments.ts` (POST handler co-located with the GET in the same file), `posts/index.ts` (POST co-located with feed GET). +- Create `apps/web/src/routes/api/public/v1/openapi[.]json.ts` — serves the public spec. +- Create `apps/web/src/lib/server/domains/api/public-openapi.ts` — registers public paths, builds the public document (separate from the admin doc in `openapi.ts`). +- Tests co-located in `__tests__/` beside each route, mirroring `apps/web/src/routes/api/v1/posts/__tests__/index.test.ts`. + +Each route file owns exactly one URL path; the auth helper and the feed query are the only shared new units. + +--- + +## Task 1: Portal-session auth helper + +**Files:** +- Create: `apps/web/src/lib/server/domains/api/portal-auth.ts` +- Test: `apps/web/src/lib/server/domains/api/__tests__/portal-auth.test.ts` + +- [ ] **Step 1: Write the failing test** + +```ts +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mockFindFirst = vi.fn() +vi.mock('@/lib/server/db', () => ({ + db: { query: { session: { findFirst: (...a: unknown[]) => mockFindFirst(...a) } } }, + session: { token: 'token', expiresAt: 'expiresAt' }, + principal: { userId: 'user_id' }, + eq: vi.fn(), and: vi.fn(), gt: vi.fn(), +})) + +import { optionalPortalSession, requirePortalSession } from '../portal-auth' +import { UnauthorizedError } from '@/lib/shared/errors' + +function req(auth?: string): Request { + return new Request('http://t/x', { headers: auth ? { authorization: auth } : {} }) +} + +describe('portal-auth', () => { + beforeEach(() => mockFindFirst.mockReset()) + + it('returns null when no bearer token is present', async () => { + expect(await optionalPortalSession(req())).toBeNull() + }) + + it('returns null when the session token is unknown/expired', async () => { + mockFindFirst.mockResolvedValue(undefined) + expect(await optionalPortalSession(req('Bearer nope'))).toBeNull() + }) + + it('returns the principal + user for a valid session', async () => { + mockFindFirst.mockResolvedValue({ + userId: 'user_1', + user: { id: 'user_1', name: 'Val', email: 'v@x.com', image: null }, + principal: { id: 'principal_1', role: 'user', type: 'user' }, + }) + const ctx = await optionalPortalSession(req('Bearer good')) + expect(ctx?.principal.id).toBe('principal_1') + expect(ctx?.user.email).toBe('v@x.com') + }) + + it('requirePortalSession throws UnauthorizedError when anonymous', async () => { + await expect(requirePortalSession(req())).rejects.toBeInstanceOf(UnauthorizedError) + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd apps/web && pnpm vitest run src/lib/server/domains/api/__tests__/portal-auth.test.ts` +Expected: FAIL — `Cannot find module '../portal-auth'`. + +- [ ] **Step 3: Write minimal implementation** + +```ts +// apps/web/src/lib/server/domains/api/portal-auth.ts +import type { PrincipalId, UserId } from '@opencoven-feedback/ids' +import type { Role } from '@/lib/server/auth' +import { db, session, principal, eq, and, gt } from '@/lib/server/db' +import { UnauthorizedError } from '@/lib/shared/errors' + +export interface PortalSession { + user: { id: UserId; email: string; name: string; image: string | null } + principal: { id: PrincipalId; role: Role; type: string } +} + +function bearer(request: Request): string | null { + const h = request.headers.get('authorization') + if (!h?.startsWith('Bearer ')) return null + const t = h.slice(7).trim() + return t.length ? t : null +} + +/** Resolve an end-user session from a bearer token, or null if absent/invalid. */ +export async function optionalPortalSession(request: Request): Promise { + const token = bearer(request) + if (!token) return null + + const row = await db.query.session.findFirst({ + where: and(eq(session.token, token), gt(session.expiresAt, new Date())), + with: { user: true, principal: true }, + }) + if (!row?.user || !row.principal) return null + + return { + user: { + id: row.user.id as UserId, + email: row.user.email!, + name: row.user.name, + image: row.user.image ?? null, + }, + principal: { + id: row.principal.id as PrincipalId, + role: row.principal.role as Role, + type: row.principal.type ?? 'user', + }, + } +} + +/** Like optionalPortalSession but throws UnauthorizedError when anonymous. */ +export async function requirePortalSession(request: Request): Promise { + const s = await optionalPortalSession(request) + if (!s) throw new UnauthorizedError('Sign in required. Provide Authorization: Bearer .') + return s +} +``` + +> NOTE: `getWidgetSession` (`lib/server/functions/widget-auth.ts`) does the same token→session→principal lookup using `getRequestHeaders()` and lazily creates a missing principal. This helper takes the `request` directly (route handlers already have it) and assumes the `session.principal` relation exists. If the Drizzle `session` relation has no `principal`, mirror `getWidgetSession`'s principal-by-`userId` lookup (`db.query.principal.findFirst({ where: eq(principal.userId, userId) })`) plus its create-if-missing block instead of the `with: { principal: true }` include. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd apps/web && pnpm vitest run src/lib/server/domains/api/__tests__/portal-auth.test.ts` +Expected: PASS (4 tests). + +- [ ] **Step 5: Commit** + +```bash +git add apps/web/src/lib/server/domains/api/portal-auth.ts apps/web/src/lib/server/domains/api/__tests__/portal-auth.test.ts +git commit -m "feat(public-api): portal-session bearer auth helper" +``` + +--- + +## Task 2: Public config endpoint (anonymous read) + +**Files:** +- Create: `apps/web/src/routes/api/public/v1/config.ts` +- Test: `apps/web/src/routes/api/public/v1/__tests__/config.test.ts` + +- [ ] **Step 1: Write the failing test** + +```ts +import { describe, expect, it, vi } from 'vitest' + +const mockGetPublicWidgetConfig = vi.fn() +vi.mock('@tanstack/react-router', () => ({ + createFileRoute: vi.fn(() => (opts: unknown) => ({ options: opts })), +})) +vi.mock('@/lib/server/domains/settings/settings.widget', () => ({ + getPublicWidgetConfig: (...a: unknown[]) => mockGetPublicWidgetConfig(...a), +})) + +import { Route } from '../config' +type Opts = { server: { handlers: { GET: () => Promise } } } +const GET = (Route as unknown as { options: Opts }).options.server.handlers.GET + +describe('GET /api/public/v1/config', () => { + it('returns the public config payload', async () => { + mockGetPublicWidgetConfig.mockResolvedValue({ enabled: true, tabs: { feedback: true }, defaultBoard: 'b1' }) + const res = await GET() + expect(res.status).toBe(200) + const json = await res.json() + expect(json.data.tabs.feedback).toBe(true) + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd apps/web && pnpm vitest run src/routes/api/public/v1/__tests__/config.test.ts` +Expected: FAIL — `Cannot find module '../config'`. + +- [ ] **Step 3: Write minimal implementation** + +```ts +// apps/web/src/routes/api/public/v1/config.ts +import { createFileRoute } from '@tanstack/react-router' +import { successResponse, handleDomainError } from '@/lib/server/domains/api/responses' + +export const Route = createFileRoute('/api/public/v1/config')({ + server: { + handlers: { + GET: async () => { + try { + const { getPublicWidgetConfig } = await import('@/lib/server/domains/settings/settings.widget') + const config = await getPublicWidgetConfig() + return successResponse(config) + } catch (error) { + return handleDomainError(error) + } + }, + }, + }, +}) +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd apps/web && pnpm vitest run src/routes/api/public/v1/__tests__/config.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add apps/web/src/routes/api/public/v1/config.ts apps/web/src/routes/api/public/v1/__tests__/config.test.ts +git commit -m "feat(public-api): GET /api/public/v1/config" +``` + +--- + +## Task 3: `listPublicPosts` feed query (the one new service) + +**Files:** +- Create: `apps/web/src/lib/server/domains/posts/post.public-list.ts` +- Test: `apps/web/src/lib/server/domains/posts/__tests__/post.public-list.test.ts` + +The admin feed uses `listInboxPosts` (`post.inbox.ts`), which includes deleted/private posts — wrong for anonymous users. Add a public-scoped list. + +- [ ] **Step 1: Write the failing test** + +```ts +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mockFindMany = vi.fn() +vi.mock('@/lib/server/db', () => ({ + db: { query: { post: { findMany: (...a: unknown[]) => mockFindMany(...a) } } }, + post: {}, board: {}, eq: vi.fn(), and: vi.fn(), desc: vi.fn(), asc: vi.fn(), lt: vi.fn(), +})) + +import { listPublicPosts } from '../post.public-list' + +describe('listPublicPosts', () => { + beforeEach(() => mockFindMany.mockReset()) + + it('requests only public, non-deleted posts and maps the result', async () => { + mockFindMany.mockResolvedValue([ + { id: 'post_1', title: 'A', voteCount: 5, deletedAt: null, board: { isPublic: true } }, + ]) + const result = await listPublicPosts({ limit: 20 }) + expect(result.items).toHaveLength(1) + expect(result.items[0].id).toBe('post_1') + expect(mockFindMany).toHaveBeenCalledTimes(1) + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd apps/web && pnpm vitest run src/lib/server/domains/posts/__tests__/post.public-list.test.ts` +Expected: FAIL — `Cannot find module '../post.public-list'`. + +- [ ] **Step 3: Write minimal implementation** + +```ts +// apps/web/src/lib/server/domains/posts/post.public-list.ts +import type { BoardId } from '@opencoven-feedback/ids' +import { db, post, board, and, eq, desc, lt } from '@/lib/server/db' + +export interface PublicPostsParams { + boardId?: BoardId + sort?: 'newest' | 'votes' + cursor?: string + limit: number +} + +export interface PublicPostSummary { + id: string + title: string + voteCount: number + statusId: string | null + boardId: string + createdAt: string +} + +/** Lists posts visible to anonymous end users: public boards, not deleted. */ +export async function listPublicPosts( + params: PublicPostsParams +): Promise<{ items: PublicPostSummary[]; cursor: string | null; hasMore: boolean }> { + const rows = await db.query.post.findMany({ + where: and( + eq(post.deletedAt, null as unknown as Date), + params.boardId ? eq(post.boardId, params.boardId) : undefined + ), + with: { board: true }, + orderBy: params.sort === 'votes' ? desc(post.voteCount) : desc(post.createdAt), + limit: params.limit + 1, + }) + + const visible = rows.filter((r) => r.board?.isPublic) + const page = visible.slice(0, params.limit) + const hasMore = visible.length > params.limit + + return { + items: page.map((r) => ({ + id: r.id, + title: r.title, + voteCount: r.voteCount, + statusId: r.statusId ?? null, + boardId: r.boardId, + createdAt: r.createdAt.toISOString(), + })), + cursor: hasMore ? page[page.length - 1].id : null, + hasMore, + } +} +``` + +> NOTE: Match column names to the actual Drizzle `post` schema in `apps/web/src/lib/server/db/schema*`. If soft-delete is a boolean (`isDeleted`) rather than `deletedAt`, and/or cursor pagination uses a keyset on `createdAt`+`id`, adjust the `where`/`orderBy` to mirror `listInboxPosts` in `post.inbox.ts`. The contract (params in, `{items,cursor,hasMore}` out) stays fixed. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd apps/web && pnpm vitest run src/lib/server/domains/posts/__tests__/post.public-list.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add apps/web/src/lib/server/domains/posts/post.public-list.ts apps/web/src/lib/server/domains/posts/__tests__/post.public-list.test.ts +git commit -m "feat(public-api): listPublicPosts feed query" +``` + +--- + +## Task 4: GET `/api/public/v1/posts` (feed) + GET `/boards` + +**Files:** +- Create: `apps/web/src/routes/api/public/v1/posts/index.ts` (feed GET; POST added in Task 9) +- Create: `apps/web/src/routes/api/public/v1/boards/index.ts` +- Test: `apps/web/src/routes/api/public/v1/posts/__tests__/index.test.ts` + +- [ ] **Step 1: Write the failing test** + +```ts +import { describe, expect, it, vi } from 'vitest' + +const mockList = vi.fn() +const mockOptional = vi.fn() +const mockVoted = vi.fn() +vi.mock('@tanstack/react-router', () => ({ createFileRoute: vi.fn(() => (o: unknown) => ({ options: o })) })) +vi.mock('@/lib/server/domains/posts/post.public-list', () => ({ listPublicPosts: (...a: unknown[]) => mockList(...a) })) +vi.mock('@/lib/server/domains/api/portal-auth', () => ({ optionalPortalSession: (...a: unknown[]) => mockOptional(...a) })) +vi.mock('@/lib/server/domains/posts/post.public', () => ({ getAllUserVotedPostIds: (...a: unknown[]) => mockVoted(...a) })) + +import { Route } from '../index' +type Opts = { server: { handlers: { GET: (a: { request: Request }) => Promise } } } +const GET = (Route as unknown as { options: Opts }).options.server.handlers.GET + +describe('GET /api/public/v1/posts', () => { + it('returns a feed and marks hasVoted when authed', async () => { + mockOptional.mockResolvedValue({ principal: { id: 'principal_1' } }) + mockVoted.mockResolvedValue(new Set(['post_1'])) + mockList.mockResolvedValue({ items: [{ id: 'post_1', title: 'A', voteCount: 2, statusId: null, boardId: 'b1', createdAt: '2026-01-01T00:00:00.000Z' }], cursor: null, hasMore: false }) + const res = await GET({ request: new Request('http://t/api/public/v1/posts?limit=20') }) + expect(res.status).toBe(200) + const json = await res.json() + expect(json.data[0].hasVoted).toBe(true) + expect(json.meta.pagination.hasMore).toBe(false) + }) + + it('works anonymously (hasVoted false)', async () => { + mockOptional.mockResolvedValue(null) + mockList.mockResolvedValue({ items: [{ id: 'post_2', title: 'B', voteCount: 0, statusId: null, boardId: 'b1', createdAt: '2026-01-01T00:00:00.000Z' }], cursor: null, hasMore: false }) + const res = await GET({ request: new Request('http://t/api/public/v1/posts') }) + const json = await res.json() + expect(json.data[0].hasVoted).toBe(false) + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd apps/web && pnpm vitest run src/routes/api/public/v1/posts/__tests__/index.test.ts` +Expected: FAIL — `Cannot find module '../index'`. + +- [ ] **Step 3: Write minimal implementation** + +```ts +// apps/web/src/routes/api/public/v1/posts/index.ts +import { createFileRoute } from '@tanstack/react-router' +import type { BoardId } from '@opencoven-feedback/ids' +import { successResponse, handleDomainError } from '@/lib/server/domains/api/responses' +import { optionalPortalSession } from '@/lib/server/domains/api/portal-auth' +import { listPublicPosts } from '@/lib/server/domains/posts/post.public-list' + +export const Route = createFileRoute('/api/public/v1/posts/')({ + server: { + handlers: { + GET: async ({ request }) => { + try { + const url = new URL(request.url) + const limit = Math.min(100, Math.max(1, parseInt(url.searchParams.get('limit') ?? '20', 10) || 20)) + const sort = (url.searchParams.get('sort') as 'newest' | 'votes') ?? 'newest' + const boardId = (url.searchParams.get('boardId') ?? undefined) as BoardId | undefined + const cursor = url.searchParams.get('cursor') ?? undefined + + const result = await listPublicPosts({ boardId, sort, cursor, limit }) + + const session = await optionalPortalSession(request) + let voted = new Set() + if (session) { + const { getAllUserVotedPostIds } = await import('@/lib/server/domains/posts/post.public') + voted = await getAllUserVotedPostIds(session.principal.id) + } + + return successResponse( + result.items.map((p) => ({ ...p, hasVoted: voted.has(p.id) })), + { pagination: { cursor: result.cursor, hasMore: result.hasMore } } + ) + } catch (error) { + return handleDomainError(error) + } + }, + }, + }, +}) +``` + +```ts +// apps/web/src/routes/api/public/v1/boards/index.ts +import { createFileRoute } from '@tanstack/react-router' +import { successResponse, handleDomainError } from '@/lib/server/domains/api/responses' + +export const Route = createFileRoute('/api/public/v1/boards/')({ + server: { + handlers: { + GET: async () => { + try { + const { listBoardsWithDetails } = await import('@/lib/server/domains/boards/board.service') + const boards = await listBoardsWithDetails() + return successResponse( + boards + .filter((b) => b.isPublic) + .map((b) => ({ id: b.id, name: b.name, slug: b.slug, description: b.description, postCount: b.postCount })) + ) + } catch (error) { + return handleDomainError(error) + } + }, + }, + }, +}) +``` + +> NOTE: Confirm `getAllUserVotedPostIds(principalId)` returns a `Set` (it is used by `api/widget/identify.ts`); if it returns an array, wrap with `new Set(...)`. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd apps/web && pnpm vitest run src/routes/api/public/v1/posts/__tests__/index.test.ts` +Expected: PASS (2 tests). + +- [ ] **Step 5: Commit** + +```bash +git add apps/web/src/routes/api/public/v1/posts/index.ts apps/web/src/routes/api/public/v1/boards/index.ts apps/web/src/routes/api/public/v1/posts/__tests__/index.test.ts +git commit -m "feat(public-api): GET posts feed + boards" +``` + +--- + +## Task 5: GET `/posts/:id` and GET `/posts/:id/comments` + +**Files:** +- Create: `apps/web/src/routes/api/public/v1/posts/$postId.ts` +- Create: `apps/web/src/routes/api/public/v1/posts/$postId.comments.ts` (GET; POST added in Task 11) +- Test: `apps/web/src/routes/api/public/v1/posts/__tests__/detail.test.ts` + +- [ ] **Step 1: Write the failing test** + +```ts +import { describe, expect, it, vi } from 'vitest' + +const mockGetPost = vi.fn() +const mockOptional = vi.fn() +const mockVoted = vi.fn() +vi.mock('@tanstack/react-router', () => ({ createFileRoute: vi.fn(() => (o: unknown) => ({ options: o })) })) +vi.mock('@/lib/server/domains/posts/post.query', () => ({ getPostWithDetails: (...a: unknown[]) => mockGetPost(...a) })) +vi.mock('@/lib/server/domains/api/portal-auth', () => ({ optionalPortalSession: (...a: unknown[]) => mockOptional(...a) })) +vi.mock('@/lib/server/domains/posts/post.public', () => ({ getAllUserVotedPostIds: (...a: unknown[]) => mockVoted(...a) })) +vi.mock('@/lib/server/domains/api/validation', () => ({ parseTypeId: (v: string) => v })) + +import { Route } from '../$postId' +type Opts = { server: { handlers: { GET: (a: { request: Request; params: { postId: string } }) => Promise } } } +const GET = (Route as unknown as { options: Opts }).options.server.handlers.GET + +describe('GET /api/public/v1/posts/:id', () => { + it('returns the post', async () => { + mockOptional.mockResolvedValue(null) + mockGetPost.mockResolvedValue({ id: 'post_1', title: 'A', content: 'x', voteCount: 3, statusId: null, boardId: 'b1', createdAt: new Date('2026-01-01') }) + const res = await GET({ request: new Request('http://t/api/public/v1/posts/post_1'), params: { postId: 'post_1' } }) + expect(res.status).toBe(200) + expect((await res.json()).data.id).toBe('post_1') + }) + + it('404s when missing', async () => { + mockOptional.mockResolvedValue(null) + mockGetPost.mockResolvedValue(null) + const res = await GET({ request: new Request('http://t/x'), params: { postId: 'post_x' } }) + expect(res.status).toBe(404) + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd apps/web && pnpm vitest run src/routes/api/public/v1/posts/__tests__/detail.test.ts` +Expected: FAIL — `Cannot find module '../$postId'`. + +- [ ] **Step 3: Write minimal implementation** + +```ts +// apps/web/src/routes/api/public/v1/posts/$postId.ts +import { createFileRoute } from '@tanstack/react-router' +import type { PostId } from '@opencoven-feedback/ids' +import { successResponse, notFoundResponse, handleDomainError } from '@/lib/server/domains/api/responses' +import { parseTypeId } from '@/lib/server/domains/api/validation' +import { optionalPortalSession } from '@/lib/server/domains/api/portal-auth' + +export const Route = createFileRoute('/api/public/v1/posts/$postId')({ + server: { + handlers: { + GET: async ({ request, params }) => { + try { + const postId = parseTypeId(params.postId, 'post', 'post ID') + const { getPostWithDetails } = await import('@/lib/server/domains/posts/post.query') + const post = await getPostWithDetails(postId) + if (!post) return notFoundResponse('Post not found') + + const session = await optionalPortalSession(request) + let hasVoted = false + if (session) { + const { getAllUserVotedPostIds } = await import('@/lib/server/domains/posts/post.public') + hasVoted = (await getAllUserVotedPostIds(session.principal.id)).has(post.id) + } + + return successResponse({ + id: post.id, title: post.title, content: post.content, + voteCount: post.voteCount, statusId: post.statusId ?? null, + boardId: post.boardId, createdAt: post.createdAt.toISOString(), hasVoted, + }) + } catch (error) { + return handleDomainError(error) + } + }, + }, + }, +}) +``` + +```ts +// apps/web/src/routes/api/public/v1/posts/$postId.comments.ts +import { createFileRoute } from '@tanstack/react-router' +import type { PostId } from '@opencoven-feedback/ids' +import { successResponse, handleDomainError } from '@/lib/server/domains/api/responses' +import { parseTypeId } from '@/lib/server/domains/api/validation' + +export const Route = createFileRoute('/api/public/v1/posts/$postId/comments')({ + server: { + handlers: { + GET: async ({ params }) => { + try { + const postId = parseTypeId(params.postId, 'post', 'post ID') + const { getCommentsWithReplies } = await import('@/lib/server/domains/posts/post.query') + const comments = await getCommentsWithReplies(postId) + const serialize = (c: { id: string; content: string; authorName: string; createdAt: Date; replies: unknown[] }): unknown => ({ + id: c.id, content: c.content, authorName: c.authorName, + createdAt: c.createdAt.toISOString(), + replies: (c.replies as typeof comments).map(serialize), + }) + return successResponse(comments.map(serialize)) + } catch (error) { + return handleDomainError(error) + } + }, + }, + }, +}) +``` + +> NOTE: `notFoundResponse` is exported from `responses.ts` (the admin routes use `NotFoundError` + `handleDomainError`; either is fine — if `notFoundResponse` doesn't exist, `throw new NotFoundError('Post not found')` and let `handleDomainError` map it to 404). Mirror the comment field names from `apps/web/src/routes/api/v1/posts/$postId.comments.ts`. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd apps/web && pnpm vitest run src/routes/api/public/v1/posts/__tests__/detail.test.ts` +Expected: PASS (2 tests). + +- [ ] **Step 5: Commit** + +```bash +git add apps/web/src/routes/api/public/v1/posts/\$postId.ts apps/web/src/routes/api/public/v1/posts/\$postId.comments.ts apps/web/src/routes/api/public/v1/posts/__tests__/detail.test.ts +git commit -m "feat(public-api): GET post detail + comments" +``` + +--- + +## Task 6: GET changelog (list + entry) + +**Files:** +- Create: `apps/web/src/routes/api/public/v1/changelog/index.ts`, `apps/web/src/routes/api/public/v1/changelog/$entryId.ts` +- Test: `apps/web/src/routes/api/public/v1/changelog/__tests__/index.test.ts` + +- [ ] **Step 1: Write the failing test** + +```ts +import { describe, expect, it, vi } from 'vitest' +const mockList = vi.fn() +vi.mock('@tanstack/react-router', () => ({ createFileRoute: vi.fn(() => (o: unknown) => ({ options: o })) })) +vi.mock('@/lib/server/domains/changelog/changelog.query', () => ({ listChangelogs: (...a: unknown[]) => mockList(...a) })) +import { Route } from '../index' +type Opts = { server: { handlers: { GET: (a: { request: Request }) => Promise } } } +const GET = (Route as unknown as { options: Opts }).options.server.handlers.GET + +describe('GET /api/public/v1/changelog', () => { + it('lists only published entries', async () => { + mockList.mockResolvedValue({ items: [{ id: 'cl_1', title: 'v1', publishedAt: new Date('2026-01-01') }], cursor: null, hasMore: false }) + const res = await GET({ request: new Request('http://t/api/public/v1/changelog') }) + expect(res.status).toBe(200) + expect(mockList).toHaveBeenCalledWith(expect.objectContaining({ status: 'published' })) + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd apps/web && pnpm vitest run src/routes/api/public/v1/changelog/__tests__/index.test.ts` +Expected: FAIL — module not found. + +- [ ] **Step 3: Write minimal implementation** + +```ts +// apps/web/src/routes/api/public/v1/changelog/index.ts +import { createFileRoute } from '@tanstack/react-router' +import { successResponse, handleDomainError } from '@/lib/server/domains/api/responses' +import { listChangelogs } from '@/lib/server/domains/changelog/changelog.query' + +export const Route = createFileRoute('/api/public/v1/changelog/')({ + server: { + handlers: { + GET: async ({ request }) => { + try { + const url = new URL(request.url) + const cursor = url.searchParams.get('cursor') ?? undefined + const limit = Math.min(100, Math.max(1, parseInt(url.searchParams.get('limit') ?? '20', 10) || 20)) + // Anonymous users only ever see published entries. + const result = await listChangelogs({ status: 'published', cursor, limit }) + return successResponse( + result.items.map((e) => ({ id: e.id, title: e.title, publishedAt: e.publishedAt?.toISOString() ?? null })), + { pagination: { cursor: result.cursor, hasMore: result.hasMore } } + ) + } catch (error) { + return handleDomainError(error) + } + }, + }, + }, +}) +``` + +```ts +// apps/web/src/routes/api/public/v1/changelog/$entryId.ts +import { createFileRoute } from '@tanstack/react-router' +import { successResponse, notFoundResponse, handleDomainError } from '@/lib/server/domains/api/responses' + +export const Route = createFileRoute('/api/public/v1/changelog/$entryId')({ + server: { + handlers: { + GET: async ({ params }) => { + try { + const { getChangelog } = await import('@/lib/server/domains/changelog/changelog.query') + const entry = await getChangelog(params.entryId) + if (!entry || entry.status !== 'published') return notFoundResponse('Changelog entry not found') + return successResponse({ id: entry.id, title: entry.title, content: entry.content, publishedAt: entry.publishedAt?.toISOString() ?? null }) + } catch (error) { + return handleDomainError(error) + } + }, + }, + }, +}) +``` + +> NOTE: Confirm `getChangelog` exists in `changelog.query.ts` (the admin `$entryId.ts` route imports the single-entry getter — use that exact name). Match `listChangelogs`'s param shape (`{ status, cursor, limit }`) — verified from `api/v1/changelog/index.ts`. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd apps/web && pnpm vitest run src/routes/api/public/v1/changelog/__tests__/index.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add apps/web/src/routes/api/public/v1/changelog/ && git commit -m "feat(public-api): GET changelog list + entry" +``` + +--- + +## Task 7: GET help-center (categories, article, search) + +**Files:** +- Create: `apps/web/src/routes/api/public/v1/help/categories/index.ts`, `apps/web/src/routes/api/public/v1/help/articles/$slug.ts`, `apps/web/src/routes/api/public/v1/help/search.ts` +- Test: `apps/web/src/routes/api/public/v1/help/__tests__/help.test.ts` + +- [ ] **Step 1: Write the failing test** + +```ts +import { describe, expect, it, vi } from 'vitest' +const mockCats = vi.fn() +vi.mock('@tanstack/react-router', () => ({ createFileRoute: vi.fn(() => (o: unknown) => ({ options: o })) })) +vi.mock('@/lib/server/domains/help-center/help-center.service', () => ({ + listCategories: (...a: unknown[]) => mockCats(...a), + listArticles: vi.fn(), getArticleBySlug: vi.fn(), +})) +import { Route } from '../categories/index' +type Opts = { server: { handlers: { GET: () => Promise } } } +const GET = (Route as unknown as { options: Opts }).options.server.handlers.GET + +describe('GET /api/public/v1/help/categories', () => { + it('returns categories', async () => { + mockCats.mockResolvedValue([{ id: 'cat_1', name: 'Getting Started', slug: 'getting-started' }]) + const res = await GET() + expect(res.status).toBe(200) + expect((await res.json()).data[0].slug).toBe('getting-started') + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd apps/web && pnpm vitest run src/routes/api/public/v1/help/__tests__/help.test.ts` +Expected: FAIL — module not found. + +- [ ] **Step 3: Write minimal implementation** + +```ts +// apps/web/src/routes/api/public/v1/help/categories/index.ts +import { createFileRoute } from '@tanstack/react-router' +import { successResponse, handleDomainError } from '@/lib/server/domains/api/responses' +import { listCategories } from '@/lib/server/domains/help-center/help-center.service' + +export const Route = createFileRoute('/api/public/v1/help/categories/')({ + server: { + handlers: { + GET: async () => { + try { + const cats = await listCategories() + return successResponse(cats.map((c) => ({ id: c.id, name: c.name, slug: c.slug, description: c.description ?? null }))) + } catch (error) { + return handleDomainError(error) + } + }, + }, + }, +}) +``` + +```ts +// apps/web/src/routes/api/public/v1/help/articles/$slug.ts +import { createFileRoute } from '@tanstack/react-router' +import { successResponse, notFoundResponse, handleDomainError } from '@/lib/server/domains/api/responses' +import { getArticleBySlug } from '@/lib/server/domains/help-center/help-center.service' + +export const Route = createFileRoute('/api/public/v1/help/articles/$slug')({ + server: { + handlers: { + GET: async ({ params }) => { + try { + const article = await getArticleBySlug(params.slug) + if (!article || article.status !== 'published') return notFoundResponse('Article not found') + return successResponse({ id: article.id, slug: article.slug, title: article.title, content: article.content, categoryId: article.categoryId }) + } catch (error) { + return handleDomainError(error) + } + }, + }, + }, +}) +``` + +```ts +// apps/web/src/routes/api/public/v1/help/search.ts +import { createFileRoute } from '@tanstack/react-router' +import { successResponse, handleDomainError } from '@/lib/server/domains/api/responses' + +export const Route = createFileRoute('/api/public/v1/help/search')({ + server: { + handlers: { + GET: async ({ request }) => { + try { + const q = new URL(request.url).searchParams.get('q')?.trim() ?? '' + if (!q) return successResponse([]) + const { searchKnowledgeBase } = await import('@/lib/server/domains/help-center/help-center.service') + const results = await searchKnowledgeBase(q) + return successResponse(results.map((r) => ({ id: r.id, slug: r.slug, title: r.title }))) + } catch (error) { + return handleDomainError(error) + } + }, + }, + }, +}) +``` + +> NOTE: `listCategories`/`listArticles` are confirmed in `help-center.service.ts`. For the single-article getter and search, use the exact names that file exports (the existing `api/widget/kb-search.ts` route already performs widget KB search — reuse the same service function it calls instead of `searchKnowledgeBase` if the name differs). Filter to `status === 'published'` for anonymous access. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd apps/web && pnpm vitest run src/routes/api/public/v1/help/__tests__/help.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add apps/web/src/routes/api/public/v1/help/ && git commit -m "feat(public-api): GET help categories, article, search" +``` + +--- + +## Task 8: POST `/posts` (submit) — auth required + +**Files:** +- Modify: `apps/web/src/routes/api/public/v1/posts/index.ts` (add POST handler) +- Test: `apps/web/src/routes/api/public/v1/posts/__tests__/submit.test.ts` + +- [ ] **Step 1: Write the failing test** + +```ts +import { describe, expect, it, vi } from 'vitest' +const mockRequire = vi.fn() +const mockCreate = vi.fn() +vi.mock('@tanstack/react-router', () => ({ createFileRoute: vi.fn(() => (o: unknown) => ({ options: o })) })) +vi.mock('@/lib/server/domains/posts/post.public-list', () => ({ listPublicPosts: vi.fn() })) +vi.mock('@/lib/server/domains/api/portal-auth', () => ({ + optionalPortalSession: vi.fn(), requirePortalSession: (...a: unknown[]) => mockRequire(...a), +})) +vi.mock('@/lib/server/domains/posts/post.service', () => ({ createPost: (...a: unknown[]) => mockCreate(...a) })) +import { Route } from '../index' +import { UnauthorizedError } from '@/lib/shared/errors' +type Opts = { server: { handlers: { POST: (a: { request: Request }) => Promise } } } +const POST = (Route as unknown as { options: Opts }).options.server.handlers.POST +const body = (b: unknown) => new Request('http://t/api/public/v1/posts', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(b) }) + +describe('POST /api/public/v1/posts', () => { + it('401s when anonymous', async () => { + mockRequire.mockRejectedValue(new UnauthorizedError('Sign in required')) + const res = await POST({ request: body({ boardId: 'b1', title: 'Hi' }) }) + expect(res.status).toBe(401) + }) + it('creates a post attributed to the session principal', async () => { + mockRequire.mockResolvedValue({ principal: { id: 'principal_1' } }) + mockCreate.mockResolvedValue({ id: 'post_new', title: 'Hi', boardId: 'b1', createdAt: new Date('2026-01-01') }) + const res = await POST({ request: body({ boardId: 'b1', title: 'Hi', content: 'x' }) }) + expect(res.status).toBe(201) + expect(mockCreate).toHaveBeenCalledWith(expect.objectContaining({ authorPrincipalId: 'principal_1' })) + }) + it('400s on invalid body', async () => { + mockRequire.mockResolvedValue({ principal: { id: 'principal_1' } }) + const res = await POST({ request: body({ title: '' }) }) + expect(res.status).toBe(400) + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd apps/web && pnpm vitest run src/routes/api/public/v1/posts/__tests__/submit.test.ts` +Expected: FAIL — `POST is not a function`. + +- [ ] **Step 3: Add the POST handler** (in `posts/index.ts`, alongside GET) + +Add these imports at the top: + +```ts +import { z } from 'zod' +import { createdResponse, badRequestResponse } from '@/lib/server/domains/api/responses' +import { requirePortalSession } from '@/lib/server/domains/api/portal-auth' + +const submitSchema = z.object({ + boardId: z.string().min(1, 'Board ID is required'), + title: z.string().min(1, 'Title is required').max(200), + content: z.string().max(10000).optional().default(''), +}) +``` + +Add the `POST` handler inside `handlers`: + +```ts +POST: async ({ request }) => { + try { + const session = await requirePortalSession(request) + const body = await request.json().catch(() => null) + const parsed = submitSchema.safeParse(body) + if (!parsed.success) { + return badRequestResponse('Invalid request body', { errors: parsed.error.flatten().fieldErrors }) + } + const { createPost } = await import('@/lib/server/domains/posts/post.service') + const post = await createPost({ + boardId: parsed.data.boardId, + title: parsed.data.title, + content: parsed.data.content, + authorPrincipalId: session.principal.id, + }) + return createdResponse({ id: post.id, title: post.title, boardId: post.boardId, createdAt: post.createdAt.toISOString() }) + } catch (error) { + return handleDomainError(error) + } +}, +``` + +> NOTE: Match `createPost`'s argument shape to `post.service.ts` (the admin `POST /api/v1/posts` calls `createPost(...)` then resolves the author principal — mirror exactly how it passes the author). `handleDomainError` must map `UnauthorizedError`→401, `ValidationError`→400; confirm in `responses.ts` (the admin routes rely on this). + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd apps/web && pnpm vitest run src/routes/api/public/v1/posts/__tests__/submit.test.ts` +Expected: PASS (3 tests). + +- [ ] **Step 5: Commit** + +```bash +git add apps/web/src/routes/api/public/v1/posts/index.ts apps/web/src/routes/api/public/v1/posts/__tests__/submit.test.ts +git commit -m "feat(public-api): POST submit post (auth required)" +``` + +--- + +## Task 9: POST `/posts/:id/vote` (toggle) — auth required + +**Files:** +- Create: `apps/web/src/routes/api/public/v1/posts/$postId.vote.ts` +- Test: `apps/web/src/routes/api/public/v1/posts/__tests__/vote.test.ts` + +- [ ] **Step 1: Write the failing test** + +```ts +import { describe, expect, it, vi } from 'vitest' +const mockRequire = vi.fn() +const mockVote = vi.fn() +vi.mock('@tanstack/react-router', () => ({ createFileRoute: vi.fn(() => (o: unknown) => ({ options: o })) })) +vi.mock('@/lib/server/domains/api/portal-auth', () => ({ requirePortalSession: (...a: unknown[]) => mockRequire(...a) })) +vi.mock('@/lib/server/domains/posts/post.voting', () => ({ voteOnPost: (...a: unknown[]) => mockVote(...a) })) +vi.mock('@/lib/server/domains/api/validation', () => ({ parseTypeId: (v: string) => v })) +import { Route } from '../$postId.vote' +type Opts = { server: { handlers: { POST: (a: { request: Request; params: { postId: string } }) => Promise } } } +const POST = (Route as unknown as { options: Opts }).options.server.handlers.POST + +describe('POST /api/public/v1/posts/:id/vote', () => { + it('toggles the vote for the session principal', async () => { + mockRequire.mockResolvedValue({ principal: { id: 'principal_1' } }) + mockVote.mockResolvedValue({ voted: true, voteCount: 6 }) + const res = await POST({ request: new Request('http://t/x', { method: 'POST' }), params: { postId: 'post_1' } }) + expect(res.status).toBe(200) + const json = await res.json() + expect(json.data).toEqual({ voted: true, voteCount: 6 }) + expect(mockVote).toHaveBeenCalledWith('post_1', 'principal_1') + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd apps/web && pnpm vitest run src/routes/api/public/v1/posts/__tests__/vote.test.ts` +Expected: FAIL — module not found. + +- [ ] **Step 3: Write minimal implementation** + +```ts +// apps/web/src/routes/api/public/v1/posts/$postId.vote.ts +import { createFileRoute } from '@tanstack/react-router' +import type { PostId } from '@opencoven-feedback/ids' +import { successResponse, handleDomainError } from '@/lib/server/domains/api/responses' +import { parseTypeId } from '@/lib/server/domains/api/validation' +import { requirePortalSession } from '@/lib/server/domains/api/portal-auth' +import { voteOnPost } from '@/lib/server/domains/posts/post.voting' + +export const Route = createFileRoute('/api/public/v1/posts/$postId/vote')({ + server: { + handlers: { + POST: async ({ request, params }) => { + try { + const session = await requirePortalSession(request) + const postId = parseTypeId(params.postId, 'post', 'post ID') + const result = await voteOnPost(postId, session.principal.id) + return successResponse({ voted: result.voted, voteCount: result.voteCount }) + } catch (error) { + return handleDomainError(error) + } + }, + }, + }, +}) +``` + +> NOTE: Confirm `voteOnPost`'s signature in `post.voting.ts` (the admin `$postId.vote.ts` calls `voteOnPost(...)`). Match its argument order and the shape of its return (`{ voted, voteCount }`); adjust the response mapping if the property names differ. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd apps/web && pnpm vitest run src/routes/api/public/v1/posts/__tests__/vote.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add apps/web/src/routes/api/public/v1/posts/\$postId.vote.ts apps/web/src/routes/api/public/v1/posts/__tests__/vote.test.ts +git commit -m "feat(public-api): POST toggle vote (auth required)" +``` + +--- + +## Task 10: POST `/posts/:id/comments` — auth required + +**Files:** +- Modify: `apps/web/src/routes/api/public/v1/posts/$postId.comments.ts` (add POST) +- Test: `apps/web/src/routes/api/public/v1/posts/__tests__/comment-create.test.ts` + +- [ ] **Step 1: Write the failing test** + +```ts +import { describe, expect, it, vi } from 'vitest' +const mockRequire = vi.fn() +const mockCreate = vi.fn() +vi.mock('@tanstack/react-router', () => ({ createFileRoute: vi.fn(() => (o: unknown) => ({ options: o })) })) +vi.mock('@/lib/server/domains/posts/post.query', () => ({ getCommentsWithReplies: vi.fn() })) +vi.mock('@/lib/server/domains/api/portal-auth', () => ({ requirePortalSession: (...a: unknown[]) => mockRequire(...a) })) +vi.mock('@/lib/server/domains/api/validation', () => ({ parseTypeId: (v: string) => v })) +vi.mock('@/lib/server/domains/posts/post.comment', () => ({ createComment: (...a: unknown[]) => mockCreate(...a) })) +import { Route } from '../$postId.comments' +type Opts = { server: { handlers: { POST: (a: { request: Request; params: { postId: string } }) => Promise } } } +const POST = (Route as unknown as { options: Opts }).options.server.handlers.POST +const body = (b: unknown) => new Request('http://t/x', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(b) }) + +describe('POST /api/public/v1/posts/:id/comments', () => { + it('creates a comment as the session principal', async () => { + mockRequire.mockResolvedValue({ principal: { id: 'principal_1' } }) + mockCreate.mockResolvedValue({ id: 'comment_1', content: 'nice', createdAt: new Date('2026-01-01') }) + const res = await POST({ request: body({ content: 'nice' }), params: { postId: 'post_1' } }) + expect(res.status).toBe(201) + expect(mockCreate).toHaveBeenCalledWith(expect.objectContaining({ authorPrincipalId: 'principal_1', content: 'nice' })) + }) + it('400s on empty content', async () => { + mockRequire.mockResolvedValue({ principal: { id: 'principal_1' } }) + const res = await POST({ request: body({ content: '' }), params: { postId: 'post_1' } }) + expect(res.status).toBe(400) + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd apps/web && pnpm vitest run src/routes/api/public/v1/posts/__tests__/comment-create.test.ts` +Expected: FAIL — `POST is not a function`. + +- [ ] **Step 3: Add the POST handler** (in `$postId.comments.ts`) + +Add imports: + +```ts +import { z } from 'zod' +import { createdResponse, badRequestResponse } from '@/lib/server/domains/api/responses' +import { requirePortalSession } from '@/lib/server/domains/api/portal-auth' + +const commentSchema = z.object({ + content: z.string().min(1, 'Content is required').max(10000), + parentId: z.string().optional(), +}) +``` + +Add the handler: + +```ts +POST: async ({ request, params }) => { + try { + const session = await requirePortalSession(request) + const postId = parseTypeId(params.postId, 'post', 'post ID') + const parsed = commentSchema.safeParse(await request.json().catch(() => null)) + if (!parsed.success) { + return badRequestResponse('Invalid request body', { errors: parsed.error.flatten().fieldErrors }) + } + const { createComment } = await import('@/lib/server/domains/posts/post.comment') + const comment = await createComment({ + postId, + content: parsed.data.content, + parentId: parsed.data.parentId, + authorPrincipalId: session.principal.id, + }) + return createdResponse({ id: comment.id, content: comment.content, createdAt: comment.createdAt.toISOString() }) + } catch (error) { + return handleDomainError(error) + } +}, +``` + +> NOTE: Find the comment-creation service used by the admin `POST /api/v1/posts/$postId/comments` (`apps/web/src/routes/api/v1/posts/$postId.comments.ts`) and import that exact function/module path here (the example assumes `createComment` in `post.comment`). Match its argument shape. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd apps/web && pnpm vitest run src/routes/api/public/v1/posts/__tests__/comment-create.test.ts` +Expected: PASS (2 tests). + +- [ ] **Step 5: Commit** + +```bash +git add apps/web/src/routes/api/public/v1/posts/\$postId.comments.ts apps/web/src/routes/api/public/v1/posts/__tests__/comment-create.test.ts +git commit -m "feat(public-api): POST create comment (auth required)" +``` + +--- + +## Task 11: Public OpenAPI document + +**Files:** +- Create: `apps/web/src/lib/server/domains/api/public-openapi.ts` +- Create: `apps/web/src/routes/api/public/v1/openapi[.]json.ts` +- Test: `apps/web/src/routes/api/public/v1/__tests__/openapi.test.ts` + +- [ ] **Step 1: Write the failing test** + +```ts +import { describe, expect, it, vi } from 'vitest' +vi.mock('@tanstack/react-router', () => ({ createFileRoute: vi.fn(() => (o: unknown) => ({ options: o })) })) +import { Route } from '../openapi[.]json' +type Opts = { server: { handlers: { GET: () => Promise } } } +const GET = (Route as unknown as { options: Opts }).options.server.handlers.GET + +describe('GET /api/public/v1/openapi.json', () => { + it('serves an OpenAPI 3.x document covering public paths', async () => { + const res = await GET() + expect(res.status).toBe(200) + const doc = await res.json() + expect(doc.openapi).toMatch(/^3\./) + expect(Object.keys(doc.paths)).toContain('/api/public/v1/posts') + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd apps/web && pnpm vitest run src/routes/api/public/v1/__tests__/openapi.test.ts` +Expected: FAIL — module not found. + +- [ ] **Step 3: Write minimal implementation** + +```ts +// apps/web/src/lib/server/domains/api/public-openapi.ts +import 'zod-openapi' +import { createDocument } from 'zod-openapi' +import { z } from 'zod' + +/** Builds the public end-user API document. Mirror the descriptor style in openapi.ts. */ +export function buildPublicOpenApiDocument(baseUrl: string) { + return createDocument({ + openapi: '3.1.0', + info: { title: 'OpenCoven Feedback — Public API', version: '1.0.0' }, + servers: [{ url: baseUrl }], + paths: { + '/api/public/v1/config': { get: { summary: 'Public widget/portal config', responses: { 200: { description: 'OK' } } } }, + '/api/public/v1/boards': { get: { summary: 'List public boards', responses: { 200: { description: 'OK' } } } }, + '/api/public/v1/posts': { + get: { summary: 'List feed posts', responses: { 200: { description: 'OK' } } }, + post: { summary: 'Submit a post (auth)', responses: { 201: { description: 'Created' }, 401: { description: 'Unauthorized' } } }, + }, + '/api/public/v1/posts/{postId}': { get: { summary: 'Get post detail', responses: { 200: { description: 'OK' }, 404: { description: 'Not found' } } } }, + '/api/public/v1/posts/{postId}/comments': { + get: { summary: 'List comments', responses: { 200: { description: 'OK' } } }, + post: { summary: 'Add comment (auth)', responses: { 201: { description: 'Created' }, 401: { description: 'Unauthorized' } } }, + }, + '/api/public/v1/posts/{postId}/vote': { post: { summary: 'Toggle vote (auth)', responses: { 200: { description: 'OK' }, 401: { description: 'Unauthorized' } } } }, + '/api/public/v1/changelog': { get: { summary: 'List changelog', responses: { 200: { description: 'OK' } } } }, + '/api/public/v1/changelog/{entryId}': { get: { summary: 'Get changelog entry', responses: { 200: { description: 'OK' }, 404: { description: 'Not found' } } } }, + '/api/public/v1/help/categories': { get: { summary: 'List help categories', responses: { 200: { description: 'OK' } } } }, + '/api/public/v1/help/articles/{slug}': { get: { summary: 'Get help article', responses: { 200: { description: 'OK' }, 404: { description: 'Not found' } } } }, + '/api/public/v1/help/search': { get: { summary: 'Search help', responses: { 200: { description: 'OK' } } } }, + }, + components: { + securitySchemes: { bearerAuth: { type: 'http', scheme: 'bearer', description: 'better-auth session token' } }, + }, + }) + void z +} +``` + +```ts +// apps/web/src/routes/api/public/v1/openapi[.]json.ts +import { createFileRoute } from '@tanstack/react-router' +import { config } from '@/lib/server/config' +import { buildPublicOpenApiDocument } from '@/lib/server/domains/api/public-openapi' + +export const Route = createFileRoute('/api/public/v1/openapi.json')({ + server: { + handlers: { + GET: async () => { + const doc = buildPublicOpenApiDocument(config.baseUrl) + return new Response(JSON.stringify(doc), { + headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*', 'Cache-Control': 'public, max-age=3600' }, + }) + }, + }, + }, +}) +``` + +> NOTE: For a richer spec, attach the Zod response schemas via `.meta()` and `registerPath` exactly as `openapi.ts` does for the admin API. The minimal document above is enough to drive `swift-openapi-generator` in Track 2; enrich incrementally. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd apps/web && pnpm vitest run src/routes/api/public/v1/__tests__/openapi.test.ts` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add apps/web/src/lib/server/domains/api/public-openapi.ts apps/web/src/routes/api/public/v1/openapi\[.\]json.ts apps/web/src/routes/api/public/v1/__tests__/openapi.test.ts +git commit -m "feat(public-api): publish /api/public/v1/openapi.json" +``` + +--- + +## Task 12: Full suite + lint gate + +- [ ] **Step 1: Run the public-API test suite** + +Run: `cd apps/web && pnpm vitest run src/routes/api/public src/lib/server/domains/api/__tests__/portal-auth.test.ts src/lib/server/domains/posts/__tests__/post.public-list.test.ts` +Expected: all PASS. + +- [ ] **Step 2: Typecheck + lint (match the repo's scripts)** + +Run: `cd apps/web && pnpm typecheck && pnpm lint` (use the script names in `apps/web/package.json`; commonly `typecheck`/`lint`) +Expected: no errors. + +- [ ] **Step 3: Open the PR** + +```bash +git push -u origin feat/public-end-user-api +gh pr create --repo OpenCoven/feedback --base main \ + --title "feat: public end-user API (/api/public/v1)" \ + --body "Adds anonymous reads + better-auth bearer writes for native/portal end-user clients, reusing existing domain services. No new business logic or migrations. Backs the native iOS app." +``` + +--- + +## Self-Review + +- **Spec coverage** (§4 of the design): config ✅ T2 · boards ✅ T4 · posts feed ✅ T3+T4 · post detail ✅ T5 · comments read ✅ T5 · changelog ✅ T6 · help ✅ T7 · submit ✅ T8 · vote ✅ T9 · comment create ✅ T10 · OpenAPI ✅ T11 · bearer auth (anon reads / session writes) ✅ T1. Rate-limiting is reused from existing middleware; if public routes need their own limiter, add `checkRateLimit(getClientIp(request))` (from `domains/api/rate-limit.ts`) at the top of each write handler — noted here so it isn't missed. +- **Placeholder scan:** No "TBD"/"handle later". The `> NOTE:` blocks are concrete "verify this exact name in file X / mirror route Y" instructions, not vague placeholders — they exist because exact service signatures must be confirmed against the live repo at execution time. +- **Type consistency:** `PortalSession.principal.id` (T1) is the principal passed to `createPost`/`voteOnPost`/`createComment` (T8/T9/T10) and `getAllUserVotedPostIds` (T4/T5). `listPublicPosts` returns `{items,cursor,hasMore}` (T3) consumed identically in T4. diff --git a/docs/superpowers/plans/2026-05-28-track2-native-ios-app.md b/docs/superpowers/plans/2026-05-28-track2-native-ios-app.md new file mode 100644 index 0000000..88191f6 --- /dev/null +++ b/docs/superpowers/plans/2026-05-28-track2-native-ios-app.md @@ -0,0 +1,1501 @@ +# Track 2 — Native Give-Feedback iOS App Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a native SwiftUI give-feedback app in `feedback-mobile` that browses boards/posts, votes, comments, submits, and reads changelog + help against the Track 1 public API — with content that updates live from the server. + +**Architecture:** A new SPM library target `FeedbackKit` holds all logic (typed API client, `AuthStore`, observable view models, models, cache) so it is unit-testable via `swift test` on macOS (no iOS SDK needed). A thin SwiftUI app target (`FeedbackPortalApp`) renders a 4-tab UI (Feedback · Changelog · Help · Account) over `FeedbackKit`. Reads are anonymous; writes attach a better-auth bearer token (email-OTP sign-in, stored in Keychain). The app is independent of the existing `OpenCovenFeedback` widget SDK. + +**Tech Stack:** Swift 5.9+, SwiftUI, Swift Concurrency, `URLSession`, `XCTest`. The API client is a hand-written `FeedbackAPI` protocol + a `URLSession`-backed implementation decoding `Codable` models that mirror the Track 1 OpenAPI contract. (A later task can swap the implementation for swift-openapi-generator output behind the same protocol without touching view models.) + +**Prerequisite:** Track 1 (`/api/public/v1`) contract is defined. View models are tested against a mock conforming to `FeedbackAPI`, so this track can proceed before the live API exists. + +**Repo:** `feedback-mobile` (this repo). New code goes under `Sources/FeedbackKit/` and `App/` with tests in `Tests/FeedbackKitTests/`. + +--- + +## File Structure + +- `Package.swift` — add `FeedbackKit` library target + `FeedbackKitTests` test target (keep the existing `OpenCovenFeedback` SDK targets untouched). +- `Sources/FeedbackKit/Config/AppConfig.swift` — instance URL. +- `Sources/FeedbackKit/Models/*.swift` — `Board`, `PostSummary`, `PostDetail`, `Comment`, `ChangelogEntry`, `HelpCategory`, `HelpArticle`, `Page` (Codable, mirror the API JSON). +- `Sources/FeedbackKit/API/FeedbackAPI.swift` — protocol (all endpoints). +- `Sources/FeedbackKit/API/HTTPFeedbackAPI.swift` — `URLSession` implementation. +- `Sources/FeedbackKit/API/APIError.swift` — typed errors incl. `.unauthorized`. +- `Sources/FeedbackKit/Auth/TokenStore.swift` — Keychain-backed token persistence (protocol + Keychain impl + in-memory test impl). +- `Sources/FeedbackKit/Auth/AuthStore.swift` — observable sign-in state + email-OTP flow. +- `Sources/FeedbackKit/Features/Feed/FeedViewModel.swift`, `Detail/PostDetailViewModel.swift`, `Submit/SubmitViewModel.swift`, `Changelog/ChangelogViewModel.swift`, `Help/HelpViewModel.swift`. +- `Sources/FeedbackKit/Cache/ContentCache.swift` — last-feed/changelog/help disk cache. +- `App/FeedbackPortalApp/*.swift` — `@main` app, `RootTabView`, per-tab SwiftUI screens, `SignInSheet`. (UI; not unit-tested.) +- `App/project.yml` additions or a new XcodeGen target; CI builds it for iOS Simulator. + +Each view model has one responsibility and depends only on `FeedbackAPI` + `AuthStore`, never on `URLSession` directly — so all are testable with a mock. + +--- + +## Task 1: Add the `FeedbackKit` library + test targets + +**Files:** +- Modify: `Package.swift` +- Create: `Sources/FeedbackKit/FeedbackKit.swift` (placeholder so the target compiles) +- Create: `Tests/FeedbackKitTests/SmokeTests.swift` + +- [ ] **Step 1: Write the failing test** + +```swift +// Tests/FeedbackKitTests/SmokeTests.swift +import XCTest +@testable import FeedbackKit + +final class SmokeTests: XCTestCase { + func testModuleLoads() { + XCTAssertEqual(FeedbackKit.name, "FeedbackKit") + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `swift test --filter FeedbackKitTests` +Expected: FAIL — no target `FeedbackKit`. + +- [ ] **Step 3: Add the targets + placeholder** + +In `Package.swift`, add to `products` and `targets`: + +```swift +.library(name: "FeedbackKit", targets: ["FeedbackKit"]), +``` + +```swift +.target(name: "FeedbackKit", path: "Sources/FeedbackKit"), +.testTarget(name: "FeedbackKitTests", dependencies: ["FeedbackKit"], path: "Tests/FeedbackKitTests"), +``` + +```swift +// Sources/FeedbackKit/FeedbackKit.swift +public enum FeedbackKit { + public static let name = "FeedbackKit" +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `swift test --filter FeedbackKitTests` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add Package.swift Sources/FeedbackKit/FeedbackKit.swift Tests/FeedbackKitTests/SmokeTests.swift +git commit -m "feat(app): scaffold FeedbackKit library + test target" +``` + +--- + +## Task 2: Codable models mirroring the API contract + +**Files:** +- Create: `Sources/FeedbackKit/Models/Models.swift` +- Test: `Tests/FeedbackKitTests/ModelsTests.swift` + +- [ ] **Step 1: Write the failing test** + +```swift +import XCTest +@testable import FeedbackKit + +final class ModelsTests: XCTestCase { + func testDecodesPostSummaryAndPageEnvelope() throws { + let json = """ + {"data":[{"id":"post_1","title":"A","voteCount":5,"statusId":null,"boardId":"b1","createdAt":"2026-01-01T00:00:00.000Z","hasVoted":true}], + "meta":{"pagination":{"cursor":null,"hasMore":false}}} + """.data(using: .utf8)! + let page = try JSONDecoder.feedback.decode(Page.self, from: json) + XCTAssertEqual(page.data.count, 1) + XCTAssertEqual(page.data[0].id, "post_1") + XCTAssertTrue(page.data[0].hasVoted) + XCTAssertFalse(page.meta?.pagination?.hasMore ?? true) + } + + func testDecodesBareDataEnvelope() throws { + let json = #"{"data":{"id":"post_1","title":"A","content":"x","voteCount":3,"statusId":null,"boardId":"b1","createdAt":"2026-01-01T00:00:00.000Z","hasVoted":false}}"#.data(using: .utf8)! + let env = try JSONDecoder.feedback.decode(Envelope.self, from: json) + XCTAssertEqual(env.data.content, "x") + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `swift test --filter ModelsTests` +Expected: FAIL — `Page`/`PostSummary` undefined. + +- [ ] **Step 3: Write minimal implementation** + +```swift +// Sources/FeedbackKit/Models/Models.swift +import Foundation + +public struct Pagination: Codable, Sendable, Equatable { + public let cursor: String? + public let hasMore: Bool +} +public struct Meta: Codable, Sendable, Equatable { + public let pagination: Pagination? +} +public struct Page: Codable, Sendable, Equatable { + public let data: [T] + public let meta: Meta? +} +public struct Envelope: Codable, Sendable, Equatable { + public let data: T +} + +public struct Board: Codable, Sendable, Equatable, Identifiable { + public let id: String + public let name: String + public let slug: String + public let description: String? + public let postCount: Int? +} +public struct PostSummary: Codable, Sendable, Equatable, Identifiable { + public let id: String + public let title: String + public let voteCount: Int + public let statusId: String? + public let boardId: String + public let createdAt: Date + public let hasVoted: Bool +} +public struct PostDetail: Codable, Sendable, Equatable, Identifiable { + public let id: String + public let title: String + public let content: String + public let voteCount: Int + public let statusId: String? + public let boardId: String + public let createdAt: Date + public let hasVoted: Bool +} +public struct Comment: Codable, Sendable, Equatable, Identifiable { + public let id: String + public let content: String + public let authorName: String + public let createdAt: Date + public let replies: [Comment] +} +public struct ChangelogEntry: Codable, Sendable, Equatable, Identifiable { + public let id: String + public let title: String + public let content: String? + public let publishedAt: Date? +} +public struct HelpCategory: Codable, Sendable, Equatable, Identifiable { + public let id: String + public let name: String + public let slug: String + public let description: String? +} +public struct HelpArticle: Codable, Sendable, Equatable, Identifiable { + public let id: String + public let slug: String + public let title: String + public let content: String + public let categoryId: String +} +public struct VoteResult: Codable, Sendable, Equatable { + public let voted: Bool + public let voteCount: Int +} + +public extension JSONDecoder { + /// ISO-8601 with fractional seconds, matching the API's `toISOString()` output. + static let feedback: JSONDecoder = { + let d = JSONDecoder() + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + d.dateDecodingStrategy = .custom { decoder in + let s = try decoder.singleValueContainer().decode(String.self) + if let date = f.date(from: s) { return date } + throw DecodingError.dataCorrupted(.init(codingPath: decoder.codingPath, debugDescription: "Bad date: \(s)")) + } + return d + }() +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `swift test --filter ModelsTests` +Expected: PASS (2 tests). + +- [ ] **Step 5: Commit** + +```bash +git add Sources/FeedbackKit/Models/Models.swift Tests/FeedbackKitTests/ModelsTests.swift +git commit -m "feat(app): Codable models + JSON envelopes" +``` + +--- + +## Task 3: `FeedbackAPI` protocol + `APIError` + +**Files:** +- Create: `Sources/FeedbackKit/API/FeedbackAPI.swift`, `Sources/FeedbackKit/API/APIError.swift` +- Test: `Tests/FeedbackKitTests/MockFeedbackAPI.swift` (test helper — no assertions yet) + +- [ ] **Step 1: Write the failing test** (a mock that must conform to the protocol) + +```swift +// Tests/FeedbackKitTests/MockFeedbackAPI.swift +@testable import FeedbackKit +import Foundation + +final class MockFeedbackAPI: FeedbackAPI, @unchecked Sendable { + var posts: [PostSummary] = [] + var voteResult = VoteResult(voted: true, voteCount: 1) + var submitted: (boardId: String, title: String, content: String)? + var shouldUnauthorize = false + + func listPosts(boardId: String?, sort: PostSort, cursor: String?) async throws -> Page { + Page(data: posts, meta: Meta(pagination: Pagination(cursor: nil, hasMore: false))) + } + func getPost(id: String) async throws -> PostDetail { + PostDetail(id: id, title: "A", content: "x", voteCount: 1, statusId: nil, boardId: "b1", createdAt: .init(), hasVoted: false) + } + func listComments(postId: String) async throws -> [Comment] { [] } + func listBoards() async throws -> [Board] { [] } + func listChangelog(cursor: String?) async throws -> Page { Page(data: [], meta: nil) } + func listHelpCategories() async throws -> [HelpCategory] { [] } + func getHelpArticle(slug: String) async throws -> HelpArticle { + HelpArticle(id: "a", slug: slug, title: "T", content: "c", categoryId: "cat") + } + func submitPost(boardId: String, title: String, content: String) async throws -> PostSummary { + if shouldUnauthorize { throw APIError.unauthorized } + submitted = (boardId, title, content) + return PostSummary(id: "post_new", title: title, voteCount: 0, statusId: nil, boardId: boardId, createdAt: .init(), hasVoted: false) + } + func vote(postId: String) async throws -> VoteResult { + if shouldUnauthorize { throw APIError.unauthorized } + return voteResult + } + func addComment(postId: String, content: String, parentId: String?) async throws -> Comment { + if shouldUnauthorize { throw APIError.unauthorized } + return Comment(id: "c1", content: content, authorName: "Me", createdAt: .init(), replies: []) + } +} + +func _mockConforms() -> FeedbackAPI { MockFeedbackAPI() } +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `swift test --filter FeedbackKitTests` +Expected: FAIL — `FeedbackAPI`/`APIError`/`PostSort` undefined. + +- [ ] **Step 3: Write minimal implementation** + +```swift +// Sources/FeedbackKit/API/APIError.swift +import Foundation + +public enum APIError: Error, Equatable, Sendable { + case unauthorized + case notFound + case rateLimited + case server(status: Int, code: String?) + case transport(String) + case decoding(String) +} +``` + +```swift +// Sources/FeedbackKit/API/FeedbackAPI.swift +import Foundation + +public enum PostSort: String, Sendable { case newest, votes } + +/// All public-API operations the app needs. View models depend on this, never URLSession. +public protocol FeedbackAPI: Sendable { + // Reads (anonymous OK) + func listPosts(boardId: String?, sort: PostSort, cursor: String?) async throws -> Page + func getPost(id: String) async throws -> PostDetail + func listComments(postId: String) async throws -> [Comment] + func listBoards() async throws -> [Board] + func listChangelog(cursor: String?) async throws -> Page + func listHelpCategories() async throws -> [HelpCategory] + func getHelpArticle(slug: String) async throws -> HelpArticle + // Writes (auth required → throw .unauthorized when no/expired token) + func submitPost(boardId: String, title: String, content: String) async throws -> PostSummary + func vote(postId: String) async throws -> VoteResult + func addComment(postId: String, content: String, parentId: String?) async throws -> Comment +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `swift test --filter FeedbackKitTests` +Expected: PASS (mock compiles & conforms). + +- [ ] **Step 5: Commit** + +```bash +git add Sources/FeedbackKit/API/FeedbackAPI.swift Sources/FeedbackKit/API/APIError.swift Tests/FeedbackKitTests/MockFeedbackAPI.swift +git commit -m "feat(app): FeedbackAPI protocol + APIError + test mock" +``` + +--- + +## Task 4: `TokenStore` (bearer token persistence) + +**Files:** +- Create: `Sources/FeedbackKit/Auth/TokenStore.swift` +- Test: `Tests/FeedbackKitTests/TokenStoreTests.swift` + +- [ ] **Step 1: Write the failing test** + +```swift +import XCTest +@testable import FeedbackKit + +final class TokenStoreTests: XCTestCase { + func testInMemoryStoreRoundTrips() { + let store = InMemoryTokenStore() + XCTAssertNil(store.token) + store.token = "abc" + XCTAssertEqual(store.token, "abc") + store.token = nil + XCTAssertNil(store.token) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `swift test --filter TokenStoreTests` +Expected: FAIL — `InMemoryTokenStore` undefined. + +- [ ] **Step 3: Write minimal implementation** + +```swift +// Sources/FeedbackKit/Auth/TokenStore.swift +import Foundation + +public protocol TokenStore: AnyObject, Sendable { + var token: String? { get set } +} + +/// Test/double store. +public final class InMemoryTokenStore: TokenStore, @unchecked Sendable { + private let lock = NSLock() + private var value: String? + public init(token: String? = nil) { self.value = token } + public var token: String? { + get { lock.lock(); defer { lock.unlock() }; return value } + set { lock.lock(); value = newValue; lock.unlock() } + } +} + +#if canImport(Security) +import Security + +/// Keychain-backed store for the device. +public final class KeychainTokenStore: TokenStore, @unchecked Sendable { + private let account: String + private let service = "dev.opencoven.feedback.session" + public init(account: String = "session-token") { self.account = account } + + public var token: String? { + get { + var query: [String: Any] = baseQuery + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + var item: CFTypeRef? + guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess, + let data = item as? Data else { return nil } + return String(data: data, encoding: .utf8) + } + set { + SecItemDelete(baseQuery as CFDictionary) + guard let newValue, let data = newValue.data(using: .utf8) else { return } + var add = baseQuery + add[kSecValueData as String] = data + SecItemAdd(add as CFDictionary, nil) + } + } + + private var baseQuery: [String: Any] { + [kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account] + } +} +#endif +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `swift test --filter TokenStoreTests` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add Sources/FeedbackKit/Auth/TokenStore.swift Tests/FeedbackKitTests/TokenStoreTests.swift +git commit -m "feat(app): TokenStore (in-memory + Keychain)" +``` + +--- + +## Task 5: `FeedViewModel` (anonymous read, the representative slice) + +**Files:** +- Create: `Sources/FeedbackKit/Features/Feed/FeedViewModel.swift` +- Test: `Tests/FeedbackKitTests/FeedViewModelTests.swift` + +- [ ] **Step 1: Write the failing test** + +```swift +import XCTest +@testable import FeedbackKit + +@MainActor +final class FeedViewModelTests: XCTestCase { + func testLoadPopulatesPostsAndClearsLoading() async { + let api = MockFeedbackAPI() + api.posts = [PostSummary(id: "post_1", title: "A", voteCount: 5, statusId: nil, boardId: "b1", createdAt: .init(), hasVoted: false)] + let vm = FeedViewModel(api: api) + await vm.load() + XCTAssertEqual(vm.posts.map(\.id), ["post_1"]) + XCTAssertFalse(vm.isLoading) + XCTAssertNil(vm.errorMessage) + } + + func testLoadFailureSetsErrorMessage() async { + final class FailingAPI: MockFeedbackAPI { + override func listPosts(boardId: String?, sort: PostSort, cursor: String?) async throws -> Page { + throw APIError.transport("offline") + } + } + let vm = FeedViewModel(api: FailingAPI()) + await vm.load() + XCTAssertTrue(vm.posts.isEmpty) + XCTAssertNotNil(vm.errorMessage) + XCTAssertFalse(vm.isLoading) + } +} +``` + +> NOTE: `MockFeedbackAPI` methods must be `open`/overridable — mark the class `open` or its methods accordingly, or make `FailingAPI` a sibling mock. Simplest: change `MockFeedbackAPI` to a non-final class with overridable methods (drop `final`). + +- [ ] **Step 2: Run test to verify it fails** + +Run: `swift test --filter FeedViewModelTests` +Expected: FAIL — `FeedViewModel` undefined. + +- [ ] **Step 3: Write minimal implementation** + +```swift +// Sources/FeedbackKit/Features/Feed/FeedViewModel.swift +import Foundation + +@MainActor +public final class FeedViewModel: ObservableObject { + @Published public private(set) var posts: [PostSummary] = [] + @Published public private(set) var isLoading = false + @Published public private(set) var errorMessage: String? + + private let api: FeedbackAPI + public var boardId: String? + public var sort: PostSort = .newest + + public init(api: FeedbackAPI) { self.api = api } + + public func load() async { + isLoading = true + errorMessage = nil + do { + let page = try await api.listPosts(boardId: boardId, sort: sort, cursor: nil) + posts = page.data + } catch { + errorMessage = Self.message(for: error) + } + isLoading = false + } + + static func message(for error: Error) -> String { + switch error { + case APIError.transport: return "You appear to be offline. Pull to retry." + case APIError.rateLimited: return "Too many requests. Try again shortly." + default: return "Something went wrong. Please try again." + } + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `swift test --filter FeedViewModelTests` +Expected: PASS (2 tests). + +- [ ] **Step 5: Commit** + +```bash +git add Sources/FeedbackKit/Features/Feed/FeedViewModel.swift Tests/FeedbackKitTests/FeedViewModelTests.swift Tests/FeedbackKitTests/MockFeedbackAPI.swift +git commit -m "feat(app): FeedViewModel with loading/error states" +``` + +--- + +## Task 6: `AuthStore` + email-OTP flow + +**Files:** +- Create: `Sources/FeedbackKit/Auth/AuthStore.swift` +- Test: `Tests/FeedbackKitTests/AuthStoreTests.swift` + +The email-OTP flow calls better-auth's existing endpoints: `POST /api/auth/email-otp/send-verification-otp` then `POST /api/auth/sign-in/email-otp`, which returns a session token. Abstract this behind an `AuthService` protocol so the store is testable. + +- [ ] **Step 1: Write the failing test** + +```swift +import XCTest +@testable import FeedbackKit + +final class StubAuthService: AuthService, @unchecked Sendable { + var sentTo: String? + var tokenToReturn = "session_tok" + func sendOTP(email: String) async throws { sentTo = email } + func verifyOTP(email: String, code: String) async throws -> String { tokenToReturn } +} + +@MainActor +final class AuthStoreTests: XCTestCase { + func testSignInStoresTokenAndFlipsState() async { + let store = InMemoryTokenStore() + let auth = AuthStore(service: StubAuthService(), tokenStore: store) + XCTAssertFalse(auth.isSignedIn) + try? await auth.requestCode(email: "v@x.com") + await auth.verify(email: "v@x.com", code: "123456") + XCTAssertTrue(auth.isSignedIn) + XCTAssertEqual(store.token, "session_tok") + } + + func testSignOutClearsToken() async { + let store = InMemoryTokenStore(token: "old") + let auth = AuthStore(service: StubAuthService(), tokenStore: store) + XCTAssertTrue(auth.isSignedIn) + auth.signOut() + XCTAssertFalse(auth.isSignedIn) + XCTAssertNil(store.token) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `swift test --filter AuthStoreTests` +Expected: FAIL — `AuthService`/`AuthStore` undefined. + +- [ ] **Step 3: Write minimal implementation** + +```swift +// Sources/FeedbackKit/Auth/AuthStore.swift +import Foundation + +public protocol AuthService: Sendable { + func sendOTP(email: String) async throws + func verifyOTP(email: String, code: String) async throws -> String // returns session token +} + +@MainActor +public final class AuthStore: ObservableObject { + @Published public private(set) var isSignedIn: Bool + @Published public private(set) var errorMessage: String? + + private let service: AuthService + private let tokenStore: TokenStore + + public init(service: AuthService, tokenStore: TokenStore) { + self.service = service + self.tokenStore = tokenStore + self.isSignedIn = tokenStore.token != nil + } + + public var token: String? { tokenStore.token } + + public func requestCode(email: String) async throws { + try await service.sendOTP(email: email) + } + + public func verify(email: String, code: String) async { + errorMessage = nil + do { + let token = try await service.verifyOTP(email: email, code: code) + tokenStore.token = token + isSignedIn = true + } catch { + errorMessage = "That code didn't work. Try again." + isSignedIn = false + } + } + + public func signOut() { + tokenStore.token = nil + isSignedIn = false + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `swift test --filter AuthStoreTests` +Expected: PASS (2 tests). + +- [ ] **Step 5: Commit** + +```bash +git add Sources/FeedbackKit/Auth/AuthStore.swift Tests/FeedbackKitTests/AuthStoreTests.swift +git commit -m "feat(app): AuthStore + email-OTP AuthService protocol" +``` + +--- + +## Task 7: `PostDetailViewModel` (detail + comments + vote with auth gating) + +**Files:** +- Create: `Sources/FeedbackKit/Features/Detail/PostDetailViewModel.swift` +- Test: `Tests/FeedbackKitTests/PostDetailViewModelTests.swift` + +- [ ] **Step 1: Write the failing test** + +```swift +import XCTest +@testable import FeedbackKit + +@MainActor +final class PostDetailViewModelTests: XCTestCase { + func testLoadFetchesPostAndComments() async { + let api = MockFeedbackAPI() + let vm = PostDetailViewModel(postId: "post_1", api: api, isSignedIn: { true }) + await vm.load() + XCTAssertEqual(vm.post?.id, "post_1") + XCTAssertNotNil(vm.comments) + } + + func testVoteUpdatesCountWhenSignedIn() async { + let api = MockFeedbackAPI() + api.voteResult = VoteResult(voted: true, voteCount: 9) + let vm = PostDetailViewModel(postId: "post_1", api: api, isSignedIn: { true }) + await vm.load() + await vm.toggleVote() + XCTAssertEqual(vm.post?.voteCount, 9) + XCTAssertEqual(vm.post?.hasVoted, true) + XCTAssertFalse(vm.needsSignIn) + } + + func testVoteWhenSignedOutRequestsSignIn() async { + let vm = PostDetailViewModel(postId: "post_1", api: MockFeedbackAPI(), isSignedIn: { false }) + await vm.load() + await vm.toggleVote() + XCTAssertTrue(vm.needsSignIn) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `swift test --filter PostDetailViewModelTests` +Expected: FAIL — `PostDetailViewModel` undefined. + +- [ ] **Step 3: Write minimal implementation** + +```swift +// Sources/FeedbackKit/Features/Detail/PostDetailViewModel.swift +import Foundation + +@MainActor +public final class PostDetailViewModel: ObservableObject { + @Published public private(set) var post: PostDetail? + @Published public private(set) var comments: [Comment] = [] + @Published public private(set) var isLoading = false + @Published public private(set) var errorMessage: String? + @Published public var needsSignIn = false + + private let postId: String + private let api: FeedbackAPI + private let isSignedIn: () -> Bool + + public init(postId: String, api: FeedbackAPI, isSignedIn: @escaping () -> Bool) { + self.postId = postId + self.api = api + self.isSignedIn = isSignedIn + } + + public func load() async { + isLoading = true; errorMessage = nil + do { + async let p = api.getPost(id: postId) + async let c = api.listComments(postId: postId) + post = try await p + comments = try await c + } catch { + errorMessage = FeedViewModel.message(for: error) + } + isLoading = false + } + + public func toggleVote() async { + guard isSignedIn() else { needsSignIn = true; return } + do { + let result = try await api.vote(postId: postId) + if var current = post { + post = PostDetail(id: current.id, title: current.title, content: current.content, + voteCount: result.voteCount, statusId: current.statusId, + boardId: current.boardId, createdAt: current.createdAt, hasVoted: result.voted) + _ = current + } + } catch APIError.unauthorized { + needsSignIn = true + } catch { + errorMessage = FeedViewModel.message(for: error) + } + } + + public func addComment(_ text: String) async { + guard isSignedIn() else { needsSignIn = true; return } + do { + let comment = try await api.addComment(postId: postId, content: text, parentId: nil) + comments.insert(comment, at: 0) + } catch APIError.unauthorized { + needsSignIn = true + } catch { + errorMessage = FeedViewModel.message(for: error) + } + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `swift test --filter PostDetailViewModelTests` +Expected: PASS (3 tests). + +- [ ] **Step 5: Commit** + +```bash +git add Sources/FeedbackKit/Features/Detail/PostDetailViewModel.swift Tests/FeedbackKitTests/PostDetailViewModelTests.swift +git commit -m "feat(app): PostDetailViewModel (vote/comment with sign-in gate)" +``` + +--- + +## Task 8: `SubmitViewModel` (auth-gated write) + +**Files:** +- Create: `Sources/FeedbackKit/Features/Submit/SubmitViewModel.swift` +- Test: `Tests/FeedbackKitTests/SubmitViewModelTests.swift` + +- [ ] **Step 1: Write the failing test** + +```swift +import XCTest +@testable import FeedbackKit + +@MainActor +final class SubmitViewModelTests: XCTestCase { + func testSubmitSucceedsWhenSignedIn() async { + let api = MockFeedbackAPI() + let vm = SubmitViewModel(api: api, isSignedIn: { true }) + vm.boardId = "b1"; vm.title = "Bug"; vm.content = "Crashes on launch" + let ok = await vm.submit() + XCTAssertTrue(ok) + XCTAssertEqual(api.submitted?.title, "Bug") + } + func testSubmitBlockedWhenSignedOut() async { + let vm = SubmitViewModel(api: MockFeedbackAPI(), isSignedIn: { false }) + vm.boardId = "b1"; vm.title = "Bug" + let ok = await vm.submit() + XCTAssertFalse(ok) + XCTAssertTrue(vm.needsSignIn) + } + func testSubmitValidatesEmptyTitle() async { + let vm = SubmitViewModel(api: MockFeedbackAPI(), isSignedIn: { true }) + vm.boardId = "b1"; vm.title = " " + let ok = await vm.submit() + XCTAssertFalse(ok) + XCTAssertEqual(vm.errorMessage, "Title is required.") + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `swift test --filter SubmitViewModelTests` +Expected: FAIL — `SubmitViewModel` undefined. + +- [ ] **Step 3: Write minimal implementation** + +```swift +// Sources/FeedbackKit/Features/Submit/SubmitViewModel.swift +import Foundation + +@MainActor +public final class SubmitViewModel: ObservableObject { + @Published public var boardId: String = "" + @Published public var title: String = "" + @Published public var content: String = "" + @Published public private(set) var isSubmitting = false + @Published public private(set) var errorMessage: String? + @Published public var needsSignIn = false + + private let api: FeedbackAPI + private let isSignedIn: () -> Bool + + public init(api: FeedbackAPI, isSignedIn: @escaping () -> Bool) { + self.api = api; self.isSignedIn = isSignedIn + } + + @discardableResult + public func submit() async -> Bool { + errorMessage = nil + guard isSignedIn() else { needsSignIn = true; return false } + guard !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + errorMessage = "Title is required."; return false + } + guard !boardId.isEmpty else { errorMessage = "Pick a board."; return false } + isSubmitting = true + defer { isSubmitting = false } + do { + _ = try await api.submitPost(boardId: boardId, title: title, content: content) + return true + } catch APIError.unauthorized { + needsSignIn = true; return false + } catch { + errorMessage = FeedViewModel.message(for: error); return false + } + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `swift test --filter SubmitViewModelTests` +Expected: PASS (3 tests). + +- [ ] **Step 5: Commit** + +```bash +git add Sources/FeedbackKit/Features/Submit/SubmitViewModel.swift Tests/FeedbackKitTests/SubmitViewModelTests.swift +git commit -m "feat(app): SubmitViewModel (validation + sign-in gate)" +``` + +--- + +## Task 9: `ChangelogViewModel` and `HelpViewModel` (read-only) + +**Files:** +- Create: `Sources/FeedbackKit/Features/Changelog/ChangelogViewModel.swift`, `Sources/FeedbackKit/Features/Help/HelpViewModel.swift` +- Test: `Tests/FeedbackKitTests/ReadOnlyViewModelsTests.swift` + +- [ ] **Step 1: Write the failing test** + +```swift +import XCTest +@testable import FeedbackKit + +@MainActor +final class ReadOnlyViewModelsTests: XCTestCase { + func testChangelogLoads() async { + final class API: MockFeedbackAPI { + override func listChangelog(cursor: String?) async throws -> Page { + Page(data: [ChangelogEntry(id: "cl_1", title: "v1", content: nil, publishedAt: .init())], meta: nil) + } + } + let vm = ChangelogViewModel(api: API()) + await vm.load() + XCTAssertEqual(vm.entries.map(\.id), ["cl_1"]) + } + + func testHelpLoadsCategories() async { + final class API: MockFeedbackAPI { + override func listHelpCategories() async throws -> [HelpCategory] { + [HelpCategory(id: "cat_1", name: "Start", slug: "start", description: nil)] + } + } + let vm = HelpViewModel(api: API()) + await vm.load() + XCTAssertEqual(vm.categories.map(\.slug), ["start"]) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `swift test --filter ReadOnlyViewModelsTests` +Expected: FAIL — view models undefined. + +- [ ] **Step 3: Write minimal implementation** + +```swift +// Sources/FeedbackKit/Features/Changelog/ChangelogViewModel.swift +import Foundation + +@MainActor +public final class ChangelogViewModel: ObservableObject { + @Published public private(set) var entries: [ChangelogEntry] = [] + @Published public private(set) var isLoading = false + @Published public private(set) var errorMessage: String? + private let api: FeedbackAPI + public init(api: FeedbackAPI) { self.api = api } + public func load() async { + isLoading = true; errorMessage = nil + do { entries = try await api.listChangelog(cursor: nil).data } + catch { errorMessage = FeedViewModel.message(for: error) } + isLoading = false + } +} +``` + +```swift +// Sources/FeedbackKit/Features/Help/HelpViewModel.swift +import Foundation + +@MainActor +public final class HelpViewModel: ObservableObject { + @Published public private(set) var categories: [HelpCategory] = [] + @Published public private(set) var isLoading = false + @Published public private(set) var errorMessage: String? + private let api: FeedbackAPI + public init(api: FeedbackAPI) { self.api = api } + public func load() async { + isLoading = true; errorMessage = nil + do { categories = try await api.listHelpCategories() } + catch { errorMessage = FeedViewModel.message(for: error) } + isLoading = false + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `swift test --filter ReadOnlyViewModelsTests` +Expected: PASS (2 tests). + +- [ ] **Step 5: Commit** + +```bash +git add Sources/FeedbackKit/Features/Changelog Sources/FeedbackKit/Features/Help Tests/FeedbackKitTests/ReadOnlyViewModelsTests.swift +git commit -m "feat(app): Changelog + Help view models" +``` + +--- + +## Task 10: `HTTPFeedbackAPI` (URLSession implementation) + +**Files:** +- Create: `Sources/FeedbackKit/API/HTTPFeedbackAPI.swift`, `Sources/FeedbackKit/Config/AppConfig.swift` +- Test: `Tests/FeedbackKitTests/HTTPFeedbackAPITests.swift` (uses `URLProtocol` stub) + +- [ ] **Step 1: Write the failing test** + +```swift +import XCTest +@testable import FeedbackKit + +final class StubURLProtocol: URLProtocol { + nonisolated(unsafe) static var handler: ((URLRequest) -> (HTTPURLResponse, Data))? + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for r: URLRequest) -> URLRequest { r } + override func startLoading() { + let (resp, data) = StubURLProtocol.handler!(request) + client?.urlProtocol(self, didReceive: resp, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: data) + client?.urlProtocolDidFinishLoading(self) + } + override func stopLoading() {} +} + +final class HTTPFeedbackAPITests: XCTestCase { + private func makeAPI(token: String? = nil) -> HTTPFeedbackAPI { + let cfg = URLSessionConfiguration.ephemeral + cfg.protocolClasses = [StubURLProtocol.self] + let session = URLSession(configuration: cfg) + return HTTPFeedbackAPI(baseURL: URL(string: "https://fb.example.com")!, + session: session, tokenProvider: { token }) + } + + func testListPostsParsesEnvelope() async throws { + StubURLProtocol.handler = { req in + XCTAssertEqual(req.url?.path, "/api/public/v1/posts") + let body = #"{"data":[{"id":"post_1","title":"A","voteCount":2,"statusId":null,"boardId":"b1","createdAt":"2026-01-01T00:00:00.000Z","hasVoted":false}],"meta":{"pagination":{"cursor":null,"hasMore":false}}}"# + return (HTTPURLResponse(url: req.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!, body.data(using: .utf8)!) + } + let page = try await makeAPI().listPosts(boardId: nil, sort: .newest, cursor: nil) + XCTAssertEqual(page.data.first?.id, "post_1") + } + + func testVoteSendsBearerAndMapsUnauthorized() async { + StubURLProtocol.handler = { req in + XCTAssertEqual(req.value(forHTTPHeaderField: "Authorization"), "Bearer tok") + return (HTTPURLResponse(url: req.url!, statusCode: 401, httpVersion: nil, headerFields: nil)!, + #"{"error":{"code":"UNAUTHORIZED","message":"x"}}"#.data(using: .utf8)!) + } + do { _ = try await makeAPI(token: "tok").vote(postId: "post_1"); XCTFail("expected throw") } + catch { XCTAssertEqual(error as? APIError, .unauthorized) } + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `swift test --filter HTTPFeedbackAPITests` +Expected: FAIL — `HTTPFeedbackAPI` undefined. + +- [ ] **Step 3: Write minimal implementation** + +```swift +// Sources/FeedbackKit/Config/AppConfig.swift +import Foundation +public struct AppConfig: Sendable { + public let instanceURL: URL + public init(instanceURL: URL) { self.instanceURL = instanceURL } +} +``` + +```swift +// Sources/FeedbackKit/API/HTTPFeedbackAPI.swift +import Foundation + +public final class HTTPFeedbackAPI: FeedbackAPI, @unchecked Sendable { + private let baseURL: URL + private let session: URLSession + private let tokenProvider: @Sendable () -> String? + + public init(baseURL: URL, session: URLSession = .shared, tokenProvider: @escaping @Sendable () -> String?) { + self.baseURL = baseURL; self.session = session; self.tokenProvider = tokenProvider + } + + // MARK: Reads + public func listPosts(boardId: String?, sort: PostSort, cursor: String?) async throws -> Page { + var q = [URLQueryItem(name: "sort", value: sort.rawValue)] + if let boardId { q.append(.init(name: "boardId", value: boardId)) } + if let cursor { q.append(.init(name: "cursor", value: cursor)) } + return try await get("/api/public/v1/posts", query: q) + } + public func getPost(id: String) async throws -> PostDetail { + try await getEnvelope("/api/public/v1/posts/\(id)") + } + public func listComments(postId: String) async throws -> [Comment] { + try await getEnvelope("/api/public/v1/posts/\(postId)/comments") + } + public func listBoards() async throws -> [Board] { try await getEnvelope("/api/public/v1/boards") } + public func listChangelog(cursor: String?) async throws -> Page { + try await get("/api/public/v1/changelog", query: cursor.map { [URLQueryItem(name: "cursor", value: $0)] } ?? []) + } + public func listHelpCategories() async throws -> [HelpCategory] { try await getEnvelope("/api/public/v1/help/categories") } + public func getHelpArticle(slug: String) async throws -> HelpArticle { try await getEnvelope("/api/public/v1/help/articles/\(slug)") } + + // MARK: Writes + public func submitPost(boardId: String, title: String, content: String) async throws -> PostSummary { + try await postEnvelope("/api/public/v1/posts", body: ["boardId": boardId, "title": title, "content": content]) + } + public func vote(postId: String) async throws -> VoteResult { + try await postEnvelope("/api/public/v1/posts/\(postId)/vote", body: [:]) + } + public func addComment(postId: String, content: String, parentId: String?) async throws -> Comment { + var body: [String: String] = ["content": content] + if let parentId { body["parentId"] = parentId } + return try await postEnvelope("/api/public/v1/posts/\(postId)/comments", body: body) + } + + // MARK: Transport + private func get(_ path: String, query: [URLQueryItem]) async throws -> Page { + try await send(request(path, method: "GET", query: query)) + } + private func getEnvelope(_ path: String) async throws -> T { + let env: Envelope = try await send(request(path, method: "GET", query: [])) + return env.data + } + private func postEnvelope(_ path: String, body: [String: String]) async throws -> T { + var req = request(path, method: "POST", query: []) + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + req.httpBody = try JSONSerialization.data(withJSONObject: body) + let env: Envelope = try await send(req) + return env.data + } + + private func request(_ path: String, method: String, query: [URLQueryItem]) -> URLRequest { + var comps = URLComponents(url: baseURL.appendingPathComponent(path), resolvingAgainstBaseURL: false)! + if !query.isEmpty { comps.queryItems = query } + var req = URLRequest(url: comps.url!) + req.httpMethod = method + if let token = tokenProvider() { req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } + return req + } + + private func send(_ req: URLRequest) async throws -> R { + let (data, response): (Data, URLResponse) + do { (data, response) = try await session.data(for: req) } + catch { throw APIError.transport(error.localizedDescription) } + guard let http = response as? HTTPURLResponse else { throw APIError.transport("No HTTP response") } + switch http.statusCode { + case 200..<300: break + case 401: throw APIError.unauthorized + case 404: throw APIError.notFound + case 429: throw APIError.rateLimited + default: throw APIError.server(status: http.statusCode, code: Self.errorCode(data)) + } + do { return try JSONDecoder.feedback.decode(R.self, from: data) } + catch { throw APIError.decoding(String(describing: error)) } + } + + private static func errorCode(_ data: Data) -> String? { + struct E: Decodable { struct Inner: Decodable { let code: String }; let error: Inner } + return (try? JSONDecoder().decode(E.self, from: data))?.error.code + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `swift test --filter HTTPFeedbackAPITests` +Expected: PASS (2 tests). + +- [ ] **Step 5: Commit** + +```bash +git add Sources/FeedbackKit/API/HTTPFeedbackAPI.swift Sources/FeedbackKit/Config/AppConfig.swift Tests/FeedbackKitTests/HTTPFeedbackAPITests.swift +git commit -m "feat(app): HTTPFeedbackAPI URLSession client (bearer + error mapping)" +``` + +--- + +## Task 11: `HTTPAuthService` (better-auth email-OTP over HTTP) + +**Files:** +- Create: `Sources/FeedbackKit/Auth/HTTPAuthService.swift` +- Test: `Tests/FeedbackKitTests/HTTPAuthServiceTests.swift` (reuses `StubURLProtocol`) + +- [ ] **Step 1: Write the failing test** + +```swift +import XCTest +@testable import FeedbackKit + +final class HTTPAuthServiceTests: XCTestCase { + private func make() -> HTTPAuthService { + let cfg = URLSessionConfiguration.ephemeral + cfg.protocolClasses = [StubURLProtocol.self] + return HTTPAuthService(baseURL: URL(string: "https://fb.example.com")!, session: URLSession(configuration: cfg)) + } + func testVerifyReturnsToken() async throws { + StubURLProtocol.handler = { req in + XCTAssertEqual(req.url?.path, "/api/auth/sign-in/email-otp") + return (HTTPURLResponse(url: req.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!, + #"{"token":"sess_123","user":{"id":"u1"}}"#.data(using: .utf8)!) + } + let token = try await make().verifyOTP(email: "v@x.com", code: "123456") + XCTAssertEqual(token, "sess_123") + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `swift test --filter HTTPAuthServiceTests` +Expected: FAIL — `HTTPAuthService` undefined. + +- [ ] **Step 3: Write minimal implementation** + +```swift +// Sources/FeedbackKit/Auth/HTTPAuthService.swift +import Foundation + +public final class HTTPAuthService: AuthService, @unchecked Sendable { + private let baseURL: URL + private let session: URLSession + public init(baseURL: URL, session: URLSession = .shared) { self.baseURL = baseURL; self.session = session } + + public func sendOTP(email: String) async throws { + _ = try await post("/api/auth/email-otp/send-verification-otp", body: ["email": email, "type": "sign-in"]) + } + + public func verifyOTP(email: String, code: String) async throws -> String { + let data = try await post("/api/auth/sign-in/email-otp", body: ["email": email, "otp": code]) + struct R: Decodable { let token: String } + guard let token = try? JSONDecoder().decode(R.self, from: data).token else { + throw APIError.decoding("No token in sign-in response") + } + return token + } + + private func post(_ path: String, body: [String: String]) async throws -> Data { + var req = URLRequest(url: baseURL.appendingPathComponent(path)) + req.httpMethod = "POST" + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + req.httpBody = try JSONSerialization.data(withJSONObject: body) + let (data, response) = try await session.data(for: req) + guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { + throw APIError.server(status: (response as? HTTPURLResponse)?.statusCode ?? -1, code: nil) + } + return data + } +} +``` + +> NOTE: Confirm the exact better-auth email-OTP endpoint paths and the field names (`email`/`otp`/`type`) and the token property in the response against the running instance (`/api/auth/*` — better-auth `emailOTP` plugin). Adjust paths/keys to match; the protocol (`AuthService`) and `AuthStore` (Task 6) do not change. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `swift test --filter HTTPAuthServiceTests` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add Sources/FeedbackKit/Auth/HTTPAuthService.swift Tests/FeedbackKitTests/HTTPAuthServiceTests.swift +git commit -m "feat(app): HTTPAuthService (better-auth email-OTP)" +``` + +--- + +## Task 12: SwiftUI app target — 4-tab shell + screens + +**Files:** +- Create: `App/FeedbackPortalApp/FeedbackPortalApp.swift`, `RootTabView.swift`, `FeedTabView.swift`, `PostDetailView.swift`, `SubmitView.swift`, `ChangelogTabView.swift`, `HelpTabView.swift`, `AccountTabView.swift`, `SignInSheet.swift`, `Environment.swift` +- Modify: `App/project.yml` (new XcodeGen target `FeedbackPortalApp` depending on `FeedbackKit`) — or add to the existing `project.yml`. + +This task is UI wiring; it is verified by the CI build (Task 13), not unit tests. Build incrementally and run the app in the simulator. + +- [ ] **Step 1: Composition root** + +```swift +// App/FeedbackPortalApp/Environment.swift +import FeedbackKit +import SwiftUI + +@MainActor +final class AppEnvironment: ObservableObject { + let api: FeedbackAPI + let auth: AuthStore + init() { + let url = URL(string: Bundle.main.object(forInfoDictionaryKey: "FEEDBACK_INSTANCE_URL") as? String ?? "http://localhost:3000")! + let tokenStore: TokenStore = KeychainTokenStore() + self.auth = AuthStore(service: HTTPAuthService(baseURL: url), tokenStore: tokenStore) + self.api = HTTPFeedbackAPI(baseURL: url, tokenProvider: { tokenStore.token }) + } +} +``` + +```swift +// App/FeedbackPortalApp/FeedbackPortalApp.swift +import SwiftUI + +@main +struct FeedbackPortalApp: App { + @StateObject private var env = AppEnvironment() + var body: some Scene { + WindowGroup { RootTabView().environmentObject(env).environmentObject(env.auth) } + } +} +``` + +- [ ] **Step 2: 4-tab shell** + +```swift +// App/FeedbackPortalApp/RootTabView.swift +import SwiftUI + +struct RootTabView: View { + var body: some View { + TabView { + FeedTabView().tabItem { Label("Feedback", systemImage: "bubble.left.and.bubble.right") } + ChangelogTabView().tabItem { Label("Changelog", systemImage: "sparkles") } + HelpTabView().tabItem { Label("Help", systemImage: "questionmark.circle") } + AccountTabView().tabItem { Label("Account", systemImage: "person.crop.circle") } + } + } +} +``` + +- [ ] **Step 3: Feed tab (list → detail → submit)** + +```swift +// App/FeedbackPortalApp/FeedTabView.swift +import FeedbackKit +import SwiftUI + +struct FeedTabView: View { + @EnvironmentObject private var env: AppEnvironment + @EnvironmentObject private var auth: AuthStore + @StateObject private var vm: FeedViewModel + @State private var showSubmit = false + + init() { _vm = StateObject(wrappedValue: FeedViewModel(api: AppEnvironment().api)) } + + var body: some View { + NavigationStack { + Group { + if vm.isLoading && vm.posts.isEmpty { ProgressView() } + else if let err = vm.errorMessage, vm.posts.isEmpty { + ContentUnavailableView("Couldn't load", systemImage: "wifi.slash", description: Text(err)) + } else { + List(vm.posts) { post in + NavigationLink(value: post.id) { + HStack { Text("\(post.voteCount)").monospacedDigit().frame(width: 36); Text(post.title) } + } + } + .refreshable { await vm.load() } + } + } + .navigationTitle("Feedback") + .toolbar { ToolbarItem(placement: .primaryAction) { Button { showSubmit = true } label: { Image(systemName: "plus") } } } + .navigationDestination(for: String.self) { PostDetailView(postId: $0) } + .sheet(isPresented: $showSubmit) { SubmitView() } + .task { await vm.load() } + } + } +} +``` + +> NOTE: The `init()` above constructs a throwaway `AppEnvironment` just to obtain `api` because `@StateObject` can't read `@EnvironmentObject` at init. Cleaner: make `FeedViewModel` lazily settable and inject `env.api` in `.task`, or pass `api` down from `RootTabView`. Pick one pattern and use it consistently for all tabs. The remaining screens (`PostDetailView`, `SubmitView`, `ChangelogTabView`, `HelpTabView`, `AccountTabView`, `SignInSheet`) follow the same shape: bind a `@StateObject` view model, render loading/error/empty/content, and present `SignInSheet` when the view model's `needsSignIn` flips true. `SignInSheet` collects an email, calls `auth.requestCode`, then a 6-digit code calling `auth.verify`. + +- [ ] **Step 4: Build & run in the simulator** + +Run: open the generated project and run `FeedbackPortalApp` on an iOS Simulator. Verify: feed loads (anonymous), tapping a post shows detail, voting/commenting/submitting prompts sign-in when signed out, changelog and help load. +Expected: golden-path flows work against a live/staging instance. + +- [ ] **Step 5: Commit** + +```bash +git add App/FeedbackPortalApp App/project.yml +git commit -m "feat(app): SwiftUI 4-tab shell + screens" +``` + +--- + +## Task 13: Offline content cache + +**Files:** +- Create: `Sources/FeedbackKit/Cache/ContentCache.swift` +- Test: `Tests/FeedbackKitTests/ContentCacheTests.swift` +- Modify: `FeedViewModel`, `ChangelogViewModel`, `HelpViewModel` to read cache on failure / seed before load. + +- [ ] **Step 1: Write the failing test** + +```swift +import XCTest +@testable import FeedbackKit + +final class ContentCacheTests: XCTestCase { + func testRoundTripsPosts() throws { + let dir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let cache = ContentCache(directory: dir) + let posts = [PostSummary(id: "post_1", title: "A", voteCount: 1, statusId: nil, boardId: "b1", createdAt: .init(), hasVoted: false)] + try cache.save(posts, as: "feed") + let loaded: [PostSummary] = try cache.load("feed", as: [PostSummary].self) + XCTAssertEqual(loaded, posts) + } + + func testLoadMissingThrows() { + let cache = ContentCache(directory: FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)) + XCTAssertThrowsError(try cache.load("nope", as: [PostSummary].self)) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `swift test --filter ContentCacheTests` +Expected: FAIL — `ContentCache` undefined. + +- [ ] **Step 3: Write minimal implementation** + +```swift +// Sources/FeedbackKit/Cache/ContentCache.swift +import Foundation + +public struct ContentCache: Sendable { + private let directory: URL + public init(directory: URL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0].appendingPathComponent("FeedbackKit")) { + self.directory = directory + try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + } + public func save(_ value: T, as key: String) throws { + let data = try JSONEncoder().encode(value) + try data.write(to: directory.appendingPathComponent("\(key).json"), options: .atomic) + } + public func load(_ key: String, as type: T.Type) throws -> T { + let data = try Data(contentsOf: directory.appendingPathComponent("\(key).json")) + return try JSONDecoder.feedback.decode(T.self, from: data) + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `swift test --filter ContentCacheTests` +Expected: PASS (2 tests). + +- [ ] **Step 5: Wire into FeedViewModel and commit** + +In `FeedViewModel.load()`: after a successful fetch, `try? cache.save(posts, as: "feed")`; in the `catch`, if `posts.isEmpty`, attempt `posts = (try? cache.load("feed", as: [PostSummary].self)) ?? []` and only show the error if the cache is also empty. Add a `cache` init parameter defaulting to `ContentCache()`. Repeat for Changelog/Help. + +```bash +git add Sources/FeedbackKit/Cache/ContentCache.swift Tests/FeedbackKitTests/ContentCacheTests.swift Sources/FeedbackKit/Features +git commit -m "feat(app): offline content cache (read-only fallback)" +``` + +--- + +## Task 14: CI — build the app for iOS + run FeedbackKit tests + +**Files:** +- Modify: `.github/workflows/ci.yml` + +- [ ] **Step 1: Add FeedbackKit to the iOS build job** + +The existing `ios-build` job runs `xcodebuild -scheme OpenCovenFeedback -destination 'generic/platform=iOS Simulator'`. Add a second build of the app: + +```yaml + - name: Build FeedbackPortalApp (iOS Simulator) + run: | + brew install xcodegen + (cd App && xcodegen generate) + xcodebuild build \ + -project App/FeedbackPortalApp.xcodeproj \ + -scheme FeedbackPortalApp \ + -sdk iphonesimulator \ + -destination 'generic/platform=iOS Simulator' \ + CODE_SIGNING_ALLOWED=NO +``` + +> NOTE: Pin a `schemes:` entry for `FeedbackPortalApp` in `App/project.yml` (XcodeGen does not emit shared schemes by default — this was the cause of an earlier CI failure on the SDK side). If XcodeGen's project-format version outruns the runner's Xcode (objectVersion mismatch — also hit earlier), prefer building the SPM `FeedbackKit` library for iOS via `xcodebuild -scheme FeedbackKit -destination 'generic/platform=iOS Simulator'` and keep the app build behind a newer-Xcode runner (`macos-15`). + +- [ ] **Step 2: Confirm `swift test` covers FeedbackKit** + +The `test` job already runs `swift test`; with `FeedbackKit` added to `Package.swift` (Task 1) it now includes all FeedbackKit tests. No change needed beyond verifying locally: + +Run: `swift test` +Expected: all `OpenCovenFeedback*` and `FeedbackKit*` tests PASS. + +- [ ] **Step 3: Commit + push + PR** + +```bash +git add .github/workflows/ci.yml App/project.yml +git commit -m "ci: build FeedbackPortalApp + run FeedbackKit tests" +git push -u origin feat/native-give-feedback-app +gh pr create --repo OpenCoven/feedback-mobile --base main \ + --title "feat: native give-feedback iOS app (FeedbackKit + FeedbackPortalApp)" \ + --body "Native SwiftUI app over the Track 1 public API: feed, post detail, vote, comment, submit, changelog, help. Email-OTP bearer auth, offline read cache. Requires Track 1 (/api/public/v1)." +``` + +--- + +## Self-Review + +- **Spec coverage** (design §5): API client ✅ T3/T10 · email-OTP bearer auth ✅ T6/T11 + Keychain T4 · Feedback (feed/detail/vote/comment/submit) ✅ T5/T7/T8 · Changelog ✅ T9 · Help ✅ T9 · Account (sign-in/out) ✅ T6 + SignInSheet T12 · offline cache ✅ T13 · 4-tab nav ✅ T12 · CI ✅ T14. `hasVoted` enrichment flows from API (T2 model) through detail VM (T7). +- **Placeholder scan:** No "TBD"/"handle later". The `> NOTE:` blocks are concrete verify/choose instructions (exact better-auth paths, the SwiftUI injection pattern, the XcodeGen scheme/objectVersion gotcha learned earlier) — not vague filler. Task 12 is explicitly UI-wiring verified by CI/simulator rather than unit tests, and names every screen file. +- **Type consistency:** `FeedbackAPI` (T3) is the exact surface implemented by `HTTPFeedbackAPI` (T10) and `MockFeedbackAPI` (T3) and consumed by every view model. `FeedViewModel.message(for:)` (T5) is reused by the detail/submit/changelog/help VMs. `AuthService` (T6) is implemented by `HTTPAuthService` (T11). `TokenStore` (T4) feeds both `AuthStore` (T6) and `HTTPFeedbackAPI.tokenProvider` (T10/T12). +- **Dependency on Track 1:** view models test against the mock, so this plan is executable before the live API exists; the HTTP client/auth-service NOTE blocks flag the fields to confirm against the running instance. From f2317e47260e123990f2e7e9a79f7810373cb9a5 Mon Sep 17 00:00:00 2001 From: apeltekci Date: Fri, 29 May 2026 07:23:00 -0700 Subject: [PATCH 03/24] feat(app): scaffold FeedbackKit library + test target --- Package.swift | 3 +++ Sources/FeedbackKit/FeedbackKit.swift | 3 +++ Tests/FeedbackKitTests/SmokeTests.swift | 8 ++++++++ 3 files changed, 14 insertions(+) create mode 100644 Sources/FeedbackKit/FeedbackKit.swift create mode 100644 Tests/FeedbackKitTests/SmokeTests.swift diff --git a/Package.swift b/Package.swift index d728522..2876340 100644 --- a/Package.swift +++ b/Package.swift @@ -6,9 +6,12 @@ let package = Package( platforms: [.iOS(.v15)], products: [ .library(name: "OpenCovenFeedback", targets: ["OpenCovenFeedback"]), + .library(name: "FeedbackKit", targets: ["FeedbackKit"]), ], targets: [ .target(name: "OpenCovenFeedback", path: "Sources/OpenCovenFeedback"), .testTarget(name: "OpenCovenFeedbackTests", dependencies: ["OpenCovenFeedback"], path: "Tests/OpenCovenFeedbackTests"), + .target(name: "FeedbackKit", path: "Sources/FeedbackKit"), + .testTarget(name: "FeedbackKitTests", dependencies: ["FeedbackKit"], path: "Tests/FeedbackKitTests"), ] ) diff --git a/Sources/FeedbackKit/FeedbackKit.swift b/Sources/FeedbackKit/FeedbackKit.swift new file mode 100644 index 0000000..e41efd1 --- /dev/null +++ b/Sources/FeedbackKit/FeedbackKit.swift @@ -0,0 +1,3 @@ +public enum FeedbackKit { + public static let name = "FeedbackKit" +} diff --git a/Tests/FeedbackKitTests/SmokeTests.swift b/Tests/FeedbackKitTests/SmokeTests.swift new file mode 100644 index 0000000..3126232 --- /dev/null +++ b/Tests/FeedbackKitTests/SmokeTests.swift @@ -0,0 +1,8 @@ +import XCTest +@testable import FeedbackKit + +final class SmokeTests: XCTestCase { + func testModuleLoads() { + XCTAssertEqual(FeedbackKit.name, "FeedbackKit") + } +} From 570bb6df1a68cce49330a52513089c3b48beae84 Mon Sep 17 00:00:00 2001 From: apeltekci Date: Fri, 29 May 2026 07:24:37 -0700 Subject: [PATCH 04/24] feat(app): Codable models + JSON envelopes Co-Authored-By: Claude Sonnet 4.6 --- Sources/FeedbackKit/Models/Models.swift | 194 +++++++++++++++++++++++ Tests/FeedbackKitTests/ModelsTests.swift | 22 +++ 2 files changed, 216 insertions(+) create mode 100644 Sources/FeedbackKit/Models/Models.swift create mode 100644 Tests/FeedbackKitTests/ModelsTests.swift diff --git a/Sources/FeedbackKit/Models/Models.swift b/Sources/FeedbackKit/Models/Models.swift new file mode 100644 index 0000000..caa6fa4 --- /dev/null +++ b/Sources/FeedbackKit/Models/Models.swift @@ -0,0 +1,194 @@ +import Foundation + +// MARK: - Envelope types + +public struct Envelope: Codable, Sendable, Equatable { + public let data: T + + public init(data: T) { + self.data = data + } +} + +public struct Pagination: Codable, Sendable, Equatable { + public let cursor: String? + public let hasMore: Bool + + public init(cursor: String?, hasMore: Bool) { + self.cursor = cursor + self.hasMore = hasMore + } +} + +public struct Meta: Codable, Sendable, Equatable { + public let pagination: Pagination? + + public init(pagination: Pagination?) { + self.pagination = pagination + } +} + +public struct Page: Codable, Sendable, Equatable { + public let data: [T] + public let meta: Meta? + + public init(data: [T], meta: Meta?) { + self.data = data + self.meta = meta + } +} + +// MARK: - Domain models + +public struct Board: Codable, Sendable, Equatable, Identifiable { + public let id: String + public let name: String + public let slug: String + public let description: String? + public let postCount: Int? + + public init(id: String, name: String, slug: String, description: String?, postCount: Int?) { + self.id = id + self.name = name + self.slug = slug + self.description = description + self.postCount = postCount + } +} + +public struct PostSummary: Codable, Sendable, Equatable, Identifiable { + public let id: String + public let title: String + public let voteCount: Int + public let statusId: String? + public let boardId: String + public let createdAt: Date + public let hasVoted: Bool + + public init(id: String, title: String, voteCount: Int, statusId: String?, boardId: String, createdAt: Date, hasVoted: Bool) { + self.id = id + self.title = title + self.voteCount = voteCount + self.statusId = statusId + self.boardId = boardId + self.createdAt = createdAt + self.hasVoted = hasVoted + } +} + +public struct PostDetail: Codable, Sendable, Equatable, Identifiable { + public let id: String + public let title: String + public let content: String + public let voteCount: Int + public let statusId: String? + public let boardId: String + public let createdAt: Date + public let hasVoted: Bool + + public init(id: String, title: String, content: String, voteCount: Int, statusId: String?, boardId: String, createdAt: Date, hasVoted: Bool) { + self.id = id + self.title = title + self.content = content + self.voteCount = voteCount + self.statusId = statusId + self.boardId = boardId + self.createdAt = createdAt + self.hasVoted = hasVoted + } +} + +public struct Comment: Codable, Sendable, Equatable, Identifiable { + public let id: String + public let content: String + public let authorName: String + public let createdAt: Date + public let replies: [Comment] + + public init(id: String, content: String, authorName: String, createdAt: Date, replies: [Comment]) { + self.id = id + self.content = content + self.authorName = authorName + self.createdAt = createdAt + self.replies = replies + } +} + +public struct ChangelogEntry: Codable, Sendable, Equatable, Identifiable { + public let id: String + public let title: String + public let content: String? + public let publishedAt: Date? + + public init(id: String, title: String, content: String?, publishedAt: Date?) { + self.id = id + self.title = title + self.content = content + self.publishedAt = publishedAt + } +} + +public struct HelpCategory: Codable, Sendable, Equatable, Identifiable { + public let id: String + public let name: String + public let slug: String + public let description: String? + + public init(id: String, name: String, slug: String, description: String?) { + self.id = id + self.name = name + self.slug = slug + self.description = description + } +} + +public struct HelpArticle: Codable, Sendable, Equatable, Identifiable { + public let id: String + public let slug: String + public let title: String + public let content: String + public let categoryId: String + + public init(id: String, slug: String, title: String, content: String, categoryId: String) { + self.id = id + self.slug = slug + self.title = title + self.content = content + self.categoryId = categoryId + } +} + +public struct VoteResult: Codable, Sendable, Equatable { + public let voted: Bool + public let voteCount: Int + + public init(voted: Bool, voteCount: Int) { + self.voted = voted + self.voteCount = voteCount + } +} + +// MARK: - JSONDecoder extension + +public extension JSONDecoder { + static let feedback: JSONDecoder = { + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + decoder.dateDecodingStrategy = .custom { dec in + let container = try dec.singleValueContainer() + let string = try container.decode(String.self) + guard let date = formatter.date(from: string) else { + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: dec.codingPath, + debugDescription: "Invalid ISO-8601 date with fractional seconds: \(string)" + ) + ) + } + return date + } + return decoder + }() +} diff --git a/Tests/FeedbackKitTests/ModelsTests.swift b/Tests/FeedbackKitTests/ModelsTests.swift new file mode 100644 index 0000000..0b564db --- /dev/null +++ b/Tests/FeedbackKitTests/ModelsTests.swift @@ -0,0 +1,22 @@ +import XCTest +@testable import FeedbackKit + +final class ModelsTests: XCTestCase { + func testDecodesPostSummaryAndPageEnvelope() throws { + let json = """ + {"data":[{"id":"post_1","title":"A","voteCount":5,"statusId":null,"boardId":"b1","createdAt":"2026-01-01T00:00:00.000Z","hasVoted":true}], + "meta":{"pagination":{"cursor":null,"hasMore":false}}} + """.data(using: .utf8)! + let page = try JSONDecoder.feedback.decode(Page.self, from: json) + XCTAssertEqual(page.data.count, 1) + XCTAssertEqual(page.data[0].id, "post_1") + XCTAssertTrue(page.data[0].hasVoted) + XCTAssertFalse(page.meta?.pagination?.hasMore ?? true) + } + + func testDecodesBareDataEnvelope() throws { + let json = #"{"data":{"id":"post_1","title":"A","content":"x","voteCount":3,"statusId":null,"boardId":"b1","createdAt":"2026-01-01T00:00:00.000Z","hasVoted":false}}"#.data(using: .utf8)! + let env = try JSONDecoder.feedback.decode(Envelope.self, from: json) + XCTAssertEqual(env.data.content, "x") + } +} From a133ca0b5839b8d39db125c72de670faa36d206d Mon Sep 17 00:00:00 2001 From: apeltekci Date: Fri, 29 May 2026 07:26:25 -0700 Subject: [PATCH 05/24] feat(app): FeedbackAPI protocol + APIError + test mock Co-Authored-By: Claude Sonnet 4.6 --- Sources/FeedbackKit/API/APIError.swift | 10 ++ Sources/FeedbackKit/API/FeedbackAPI.swift | 16 +++ Tests/FeedbackKitTests/MockFeedbackAPI.swift | 106 ++++++++++++++++++ .../ProtocolConformanceTests.swift | 51 +++++++++ 4 files changed, 183 insertions(+) create mode 100644 Sources/FeedbackKit/API/APIError.swift create mode 100644 Sources/FeedbackKit/API/FeedbackAPI.swift create mode 100644 Tests/FeedbackKitTests/MockFeedbackAPI.swift create mode 100644 Tests/FeedbackKitTests/ProtocolConformanceTests.swift diff --git a/Sources/FeedbackKit/API/APIError.swift b/Sources/FeedbackKit/API/APIError.swift new file mode 100644 index 0000000..442bb21 --- /dev/null +++ b/Sources/FeedbackKit/API/APIError.swift @@ -0,0 +1,10 @@ +import Foundation + +public enum APIError: Error, Equatable, Sendable { + case unauthorized + case notFound + case rateLimited + case server(status: Int, code: String?) + case transport(String) + case decoding(String) +} diff --git a/Sources/FeedbackKit/API/FeedbackAPI.swift b/Sources/FeedbackKit/API/FeedbackAPI.swift new file mode 100644 index 0000000..b69b29b --- /dev/null +++ b/Sources/FeedbackKit/API/FeedbackAPI.swift @@ -0,0 +1,16 @@ +import Foundation + +public enum PostSort: String, Sendable { case newest, votes } + +public protocol FeedbackAPI: Sendable { + func listPosts(boardId: String?, sort: PostSort, cursor: String?) async throws -> Page + func getPost(id: String) async throws -> PostDetail + func listComments(postId: String) async throws -> [Comment] + func listBoards() async throws -> [Board] + func listChangelog(cursor: String?) async throws -> Page + func listHelpCategories() async throws -> [HelpCategory] + func getHelpArticle(slug: String) async throws -> HelpArticle + func submitPost(boardId: String, title: String, content: String) async throws -> PostSummary + func vote(postId: String) async throws -> VoteResult + func addComment(postId: String, content: String, parentId: String?) async throws -> Comment +} diff --git a/Tests/FeedbackKitTests/MockFeedbackAPI.swift b/Tests/FeedbackKitTests/MockFeedbackAPI.swift new file mode 100644 index 0000000..d367d0f --- /dev/null +++ b/Tests/FeedbackKitTests/MockFeedbackAPI.swift @@ -0,0 +1,106 @@ +import Foundation +@testable import FeedbackKit + +// MARK: - MockFeedbackAPI +// Non-final so subclasses can override individual methods to inject failures. +class MockFeedbackAPI: @unchecked Sendable, FeedbackAPI { + + // MARK: Canned data (mutable so tests can configure per-scenario) + + var posts: [PostSummary] = [] + var postDetail = PostDetail( + id: "post_1", + title: "Test Post", + content: "Content", + voteCount: 0, + statusId: nil, + boardId: "board_1", + createdAt: Date(timeIntervalSince1970: 0), + hasVoted: false + ) + var comments: [Comment] = [] + var boards: [Board] = [] + var changelogEntries: [ChangelogEntry] = [] + var helpCategories: [HelpCategory] = [] + var helpArticle = HelpArticle( + id: "article_1", + slug: "getting-started", + title: "Getting Started", + content: "Welcome.", + categoryId: "cat_1" + ) + var voteResult = VoteResult(voted: true, voteCount: 1) + + // MARK: Failure injection + + var shouldUnauthorize = false + + // MARK: Recorded inputs + + var submitted: (boardId: String, title: String, content: String)? + var votedPostId: String? + var addedComment: (postId: String, content: String, parentId: String?)? + + // MARK: FeedbackAPI conformance + + func listPosts(boardId: String?, sort: PostSort, cursor: String?) async throws -> Page { + Page(data: posts, meta: nil) + } + + func getPost(id: String) async throws -> PostDetail { + postDetail + } + + func listComments(postId: String) async throws -> [Comment] { + comments + } + + func listBoards() async throws -> [Board] { + boards + } + + func listChangelog(cursor: String?) async throws -> Page { + Page(data: changelogEntries, meta: nil) + } + + func listHelpCategories() async throws -> [HelpCategory] { + helpCategories + } + + func getHelpArticle(slug: String) async throws -> HelpArticle { + helpArticle + } + + func submitPost(boardId: String, title: String, content: String) async throws -> PostSummary { + if shouldUnauthorize { throw APIError.unauthorized } + submitted = (boardId: boardId, title: title, content: content) + let post = PostSummary( + id: "post_new", + title: title, + voteCount: 0, + statusId: nil, + boardId: boardId, + createdAt: Date(timeIntervalSince1970: 0), + hasVoted: false + ) + return post + } + + func vote(postId: String) async throws -> VoteResult { + if shouldUnauthorize { throw APIError.unauthorized } + votedPostId = postId + return voteResult + } + + func addComment(postId: String, content: String, parentId: String?) async throws -> Comment { + if shouldUnauthorize { throw APIError.unauthorized } + addedComment = (postId: postId, content: content, parentId: parentId) + return Comment( + id: "comment_new", + content: content, + authorName: "Test User", + createdAt: Date(timeIntervalSince1970: 0), + replies: [] + ) + } +} diff --git a/Tests/FeedbackKitTests/ProtocolConformanceTests.swift b/Tests/FeedbackKitTests/ProtocolConformanceTests.swift new file mode 100644 index 0000000..5901d55 --- /dev/null +++ b/Tests/FeedbackKitTests/ProtocolConformanceTests.swift @@ -0,0 +1,51 @@ +import XCTest +@testable import FeedbackKit + +final class ProtocolConformanceTests: XCTestCase { + + func testMockConformsToFeedbackAPI() async throws { + // Assigning to the protocol type proves conformance at compile time. + let api: FeedbackAPI = MockFeedbackAPI() + let page = try await api.listPosts(boardId: nil, sort: .newest, cursor: nil) + XCTAssertEqual(page.data.count, 0) + } + + func testShouldUnauthorizeThrowsOnWrite() async { + let mock = MockFeedbackAPI() + mock.shouldUnauthorize = true + + do { + _ = try await mock.submitPost(boardId: "b", title: "T", content: "C") + XCTFail("Expected unauthorized error") + } catch APIError.unauthorized { + // expected + } catch { + XCTFail("Unexpected error: \(error)") + } + } + + func testRecordsSubmittedInputs() async throws { + let mock = MockFeedbackAPI() + _ = try await mock.submitPost(boardId: "b1", title: "My Post", content: "Body") + XCTAssertEqual(mock.submitted?.boardId, "b1") + XCTAssertEqual(mock.submitted?.title, "My Post") + XCTAssertEqual(mock.submitted?.content, "Body") + } + + func testRecordsVotePostId() async throws { + let mock = MockFeedbackAPI() + let result = try await mock.vote(postId: "post_42") + XCTAssertEqual(mock.votedPostId, "post_42") + XCTAssertTrue(result.voted) + XCTAssertEqual(result.voteCount, 1) + } + + func testRecordsAddedComment() async throws { + let mock = MockFeedbackAPI() + let comment = try await mock.addComment(postId: "post_1", content: "Nice!", parentId: nil) + XCTAssertEqual(mock.addedComment?.postId, "post_1") + XCTAssertEqual(mock.addedComment?.content, "Nice!") + XCTAssertNil(mock.addedComment?.parentId) + XCTAssertEqual(comment.content, "Nice!") + } +} From f2cc75f805eafddc5a5cfbfe5a0263104ba44ded Mon Sep 17 00:00:00 2001 From: apeltekci Date: Fri, 29 May 2026 07:27:53 -0700 Subject: [PATCH 06/24] feat(app): TokenStore (in-memory + Keychain) --- Sources/FeedbackKit/Auth/TokenStore.swift | 83 ++++++++++++++++++++ Tests/FeedbackKitTests/TokenStoreTests.swift | 13 +++ 2 files changed, 96 insertions(+) create mode 100644 Sources/FeedbackKit/Auth/TokenStore.swift create mode 100644 Tests/FeedbackKitTests/TokenStoreTests.swift diff --git a/Sources/FeedbackKit/Auth/TokenStore.swift b/Sources/FeedbackKit/Auth/TokenStore.swift new file mode 100644 index 0000000..15cdebd --- /dev/null +++ b/Sources/FeedbackKit/Auth/TokenStore.swift @@ -0,0 +1,83 @@ +import Foundation +#if canImport(Security) +import Security +#endif + +// MARK: - Protocol + +public protocol TokenStore: AnyObject, Sendable { + var token: String? { get set } +} + +// MARK: - InMemoryTokenStore + +public final class InMemoryTokenStore: TokenStore, @unchecked Sendable { + private let lock = NSLock() + private var _token: String? + + public init(token: String? = nil) { + _token = token + } + + public var token: String? { + get { + lock.lock() + defer { lock.unlock() } + return _token + } + set { + lock.lock() + defer { lock.unlock() } + _token = newValue + } + } +} + +// MARK: - KeychainTokenStore + +#if canImport(Security) +public final class KeychainTokenStore: TokenStore, @unchecked Sendable { + private let service: String + private let account: String + + public init( + service: String = "dev.opencoven.feedback.session", + account: String = "session-token" + ) { + self.service = service + self.account = account + } + + private var baseQuery: [CFString: Any] { + [ + kSecClass: kSecClassGenericPassword, + kSecAttrService: service, + kSecAttrAccount: account, + ] + } + + public var token: String? { + get { + var query = baseQuery + query[kSecReturnData] = kCFBooleanTrue + query[kSecMatchLimit] = kSecMatchLimitOne + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + guard status == errSecSuccess, let data = result as? Data else { + return nil + } + return String(data: data, encoding: .utf8) + } + set { + SecItemDelete(baseQuery as CFDictionary) + guard let value = newValue, let data = value.data(using: .utf8) else { + return + } + var addQuery = baseQuery + addQuery[kSecValueData] = data + SecItemAdd(addQuery as CFDictionary, nil) + } + } +} +#endif diff --git a/Tests/FeedbackKitTests/TokenStoreTests.swift b/Tests/FeedbackKitTests/TokenStoreTests.swift new file mode 100644 index 0000000..7fdb33a --- /dev/null +++ b/Tests/FeedbackKitTests/TokenStoreTests.swift @@ -0,0 +1,13 @@ +import XCTest +@testable import FeedbackKit + +final class TokenStoreTests: XCTestCase { + func testInMemoryStoreRoundTrips() { + let store = InMemoryTokenStore() + XCTAssertNil(store.token) + store.token = "abc" + XCTAssertEqual(store.token, "abc") + store.token = nil + XCTAssertNil(store.token) + } +} From 564ee055b833c4900a5077462548677d23d9104c Mon Sep 17 00:00:00 2001 From: apeltekci Date: Fri, 29 May 2026 07:29:43 -0700 Subject: [PATCH 07/24] feat(app): FeedViewModel with loading/error states Co-Authored-By: Claude Sonnet 4.6 --- Package.swift | 2 +- .../Features/Feed/FeedViewModel.swift | 34 +++++++++++++++++++ .../FeedbackKitTests/FeedViewModelTests.swift | 28 +++++++++++++++ 3 files changed, 63 insertions(+), 1 deletion(-) create mode 100644 Sources/FeedbackKit/Features/Feed/FeedViewModel.swift create mode 100644 Tests/FeedbackKitTests/FeedViewModelTests.swift diff --git a/Package.swift b/Package.swift index 2876340..433e098 100644 --- a/Package.swift +++ b/Package.swift @@ -3,7 +3,7 @@ import PackageDescription let package = Package( name: "OpenCovenFeedback", - platforms: [.iOS(.v15)], + platforms: [.iOS(.v15), .macOS(.v12)], products: [ .library(name: "OpenCovenFeedback", targets: ["OpenCovenFeedback"]), .library(name: "FeedbackKit", targets: ["FeedbackKit"]), diff --git a/Sources/FeedbackKit/Features/Feed/FeedViewModel.swift b/Sources/FeedbackKit/Features/Feed/FeedViewModel.swift new file mode 100644 index 0000000..4316edd --- /dev/null +++ b/Sources/FeedbackKit/Features/Feed/FeedViewModel.swift @@ -0,0 +1,34 @@ +import Foundation + +@MainActor +public final class FeedViewModel: ObservableObject { + @Published public private(set) var posts: [PostSummary] = [] + @Published public private(set) var isLoading = false + @Published public private(set) var errorMessage: String? + + private let api: FeedbackAPI + public var boardId: String? + public var sort: PostSort = .newest + + public init(api: FeedbackAPI) { self.api = api } + + public func load() async { + isLoading = true + errorMessage = nil + do { + let page = try await api.listPosts(boardId: boardId, sort: sort, cursor: nil) + posts = page.data + } catch { + errorMessage = Self.message(for: error) + } + isLoading = false + } + + static func message(for error: Error) -> String { + switch error { + case APIError.transport: return "You appear to be offline. Pull to retry." + case APIError.rateLimited: return "Too many requests. Try again shortly." + default: return "Something went wrong. Please try again." + } + } +} diff --git a/Tests/FeedbackKitTests/FeedViewModelTests.swift b/Tests/FeedbackKitTests/FeedViewModelTests.swift new file mode 100644 index 0000000..8dbfdf3 --- /dev/null +++ b/Tests/FeedbackKitTests/FeedViewModelTests.swift @@ -0,0 +1,28 @@ +import XCTest +@testable import FeedbackKit + +@MainActor +final class FeedViewModelTests: XCTestCase { + func testLoadPopulatesPostsAndClearsLoading() async { + let api = MockFeedbackAPI() + api.posts = [PostSummary(id: "post_1", title: "A", voteCount: 5, statusId: nil, boardId: "b1", createdAt: .init(), hasVoted: false)] + let vm = FeedViewModel(api: api) + await vm.load() + XCTAssertEqual(vm.posts.map(\.id), ["post_1"]) + XCTAssertFalse(vm.isLoading) + XCTAssertNil(vm.errorMessage) + } + + func testLoadFailureSetsErrorMessage() async { + final class FailingAPI: MockFeedbackAPI { + override func listPosts(boardId: String?, sort: PostSort, cursor: String?) async throws -> Page { + throw APIError.transport("offline") + } + } + let vm = FeedViewModel(api: FailingAPI()) + await vm.load() + XCTAssertTrue(vm.posts.isEmpty) + XCTAssertNotNil(vm.errorMessage) + XCTAssertFalse(vm.isLoading) + } +} From d39c8fbcccfb3811f6df8a87bcb7b15de636d8f4 Mon Sep 17 00:00:00 2001 From: apeltekci Date: Fri, 29 May 2026 07:31:05 -0700 Subject: [PATCH 08/24] feat(app): AuthStore + email-OTP AuthService protocol --- Sources/FeedbackKit/Auth/AuthStore.swift | 44 +++++++++++++++++++++ Tests/FeedbackKitTests/AuthStoreTests.swift | 31 +++++++++++++++ 2 files changed, 75 insertions(+) create mode 100644 Sources/FeedbackKit/Auth/AuthStore.swift create mode 100644 Tests/FeedbackKitTests/AuthStoreTests.swift diff --git a/Sources/FeedbackKit/Auth/AuthStore.swift b/Sources/FeedbackKit/Auth/AuthStore.swift new file mode 100644 index 0000000..d8d84b0 --- /dev/null +++ b/Sources/FeedbackKit/Auth/AuthStore.swift @@ -0,0 +1,44 @@ +import Foundation + +public protocol AuthService: Sendable { + func sendOTP(email: String) async throws + func verifyOTP(email: String, code: String) async throws -> String // returns session token +} + +@MainActor +public final class AuthStore: ObservableObject { + @Published public private(set) var isSignedIn: Bool + @Published public private(set) var errorMessage: String? + + private let service: AuthService + private let tokenStore: TokenStore + + public init(service: AuthService, tokenStore: TokenStore) { + self.service = service + self.tokenStore = tokenStore + self.isSignedIn = tokenStore.token != nil + } + + public var token: String? { tokenStore.token } + + public func requestCode(email: String) async throws { + try await service.sendOTP(email: email) + } + + public func verify(email: String, code: String) async { + errorMessage = nil + do { + let token = try await service.verifyOTP(email: email, code: code) + tokenStore.token = token + isSignedIn = true + } catch { + errorMessage = "That code didn't work. Try again." + isSignedIn = false + } + } + + public func signOut() { + tokenStore.token = nil + isSignedIn = false + } +} diff --git a/Tests/FeedbackKitTests/AuthStoreTests.swift b/Tests/FeedbackKitTests/AuthStoreTests.swift new file mode 100644 index 0000000..7c3816f --- /dev/null +++ b/Tests/FeedbackKitTests/AuthStoreTests.swift @@ -0,0 +1,31 @@ +import XCTest +@testable import FeedbackKit + +final class StubAuthService: AuthService, @unchecked Sendable { + var sentTo: String? + var tokenToReturn = "session_tok" + func sendOTP(email: String) async throws { sentTo = email } + func verifyOTP(email: String, code: String) async throws -> String { tokenToReturn } +} + +@MainActor +final class AuthStoreTests: XCTestCase { + func testSignInStoresTokenAndFlipsState() async { + let store = InMemoryTokenStore() + let auth = AuthStore(service: StubAuthService(), tokenStore: store) + XCTAssertFalse(auth.isSignedIn) + try? await auth.requestCode(email: "v@x.com") + await auth.verify(email: "v@x.com", code: "123456") + XCTAssertTrue(auth.isSignedIn) + XCTAssertEqual(store.token, "session_tok") + } + + func testSignOutClearsToken() async { + let store = InMemoryTokenStore(token: "old") + let auth = AuthStore(service: StubAuthService(), tokenStore: store) + XCTAssertTrue(auth.isSignedIn) + auth.signOut() + XCTAssertFalse(auth.isSignedIn) + XCTAssertNil(store.token) + } +} From bd9f29311866294303c37b58793f4909a817bd39 Mon Sep 17 00:00:00 2001 From: apeltekci Date: Fri, 29 May 2026 07:33:02 -0700 Subject: [PATCH 09/24] feat(app): PostDetailViewModel (vote/comment with sign-in gate) --- .../Features/Detail/PostDetailViewModel.swift | 68 +++++++++++++++++++ .../PostDetailViewModelTests.swift | 31 +++++++++ 2 files changed, 99 insertions(+) create mode 100644 Sources/FeedbackKit/Features/Detail/PostDetailViewModel.swift create mode 100644 Tests/FeedbackKitTests/PostDetailViewModelTests.swift diff --git a/Sources/FeedbackKit/Features/Detail/PostDetailViewModel.swift b/Sources/FeedbackKit/Features/Detail/PostDetailViewModel.swift new file mode 100644 index 0000000..4a71898 --- /dev/null +++ b/Sources/FeedbackKit/Features/Detail/PostDetailViewModel.swift @@ -0,0 +1,68 @@ +import Foundation + +@MainActor +public final class PostDetailViewModel: ObservableObject { + @Published public private(set) var post: PostDetail? + @Published public private(set) var comments: [Comment] = [] + @Published public private(set) var isLoading = false + @Published public private(set) var errorMessage: String? + @Published public var needsSignIn = false + + private let postId: String + private let api: FeedbackAPI + private let isSignedIn: () -> Bool + + public init(postId: String, api: FeedbackAPI, isSignedIn: @escaping () -> Bool) { + self.postId = postId + self.api = api + self.isSignedIn = isSignedIn + } + + public func load() async { + isLoading = true; errorMessage = nil + do { + async let p = api.getPost(id: postId) + async let c = api.listComments(postId: postId) + post = try await p + comments = try await c + } catch { + errorMessage = FeedViewModel.message(for: error) + } + isLoading = false + } + + public func toggleVote() async { + guard isSignedIn() else { needsSignIn = true; return } + do { + let result = try await api.vote(postId: postId) + if let current = post { + post = PostDetail( + id: current.id, + title: current.title, + content: current.content, + voteCount: result.voteCount, + statusId: current.statusId, + boardId: current.boardId, + createdAt: current.createdAt, + hasVoted: result.voted + ) + } + } catch APIError.unauthorized { + needsSignIn = true + } catch { + errorMessage = FeedViewModel.message(for: error) + } + } + + public func addComment(_ text: String) async { + guard isSignedIn() else { needsSignIn = true; return } + do { + let comment = try await api.addComment(postId: postId, content: text, parentId: nil) + comments.insert(comment, at: 0) + } catch APIError.unauthorized { + needsSignIn = true + } catch { + errorMessage = FeedViewModel.message(for: error) + } + } +} diff --git a/Tests/FeedbackKitTests/PostDetailViewModelTests.swift b/Tests/FeedbackKitTests/PostDetailViewModelTests.swift new file mode 100644 index 0000000..ea1bd12 --- /dev/null +++ b/Tests/FeedbackKitTests/PostDetailViewModelTests.swift @@ -0,0 +1,31 @@ +import XCTest +@testable import FeedbackKit + +@MainActor +final class PostDetailViewModelTests: XCTestCase { + func testLoadFetchesPostAndComments() async { + let api = MockFeedbackAPI() + let vm = PostDetailViewModel(postId: "post_1", api: api, isSignedIn: { true }) + await vm.load() + XCTAssertEqual(vm.post?.id, "post_1") + XCTAssertNotNil(vm.comments) + } + + func testVoteUpdatesCountWhenSignedIn() async { + let api = MockFeedbackAPI() + api.voteResult = VoteResult(voted: true, voteCount: 9) + let vm = PostDetailViewModel(postId: "post_1", api: api, isSignedIn: { true }) + await vm.load() + await vm.toggleVote() + XCTAssertEqual(vm.post?.voteCount, 9) + XCTAssertEqual(vm.post?.hasVoted, true) + XCTAssertFalse(vm.needsSignIn) + } + + func testVoteWhenSignedOutRequestsSignIn() async { + let vm = PostDetailViewModel(postId: "post_1", api: MockFeedbackAPI(), isSignedIn: { false }) + await vm.load() + await vm.toggleVote() + XCTAssertTrue(vm.needsSignIn) + } +} From be5b5e232c40f4d613517cb877226c1f0cf85996 Mon Sep 17 00:00:00 2001 From: apeltekci Date: Fri, 29 May 2026 07:34:22 -0700 Subject: [PATCH 10/24] feat(app): SubmitViewModel (validation + sign-in gate) --- .../Features/Submit/SubmitViewModel.swift | 38 +++++++++++++++++++ .../SubmitViewModelTests.swift | 28 ++++++++++++++ 2 files changed, 66 insertions(+) create mode 100644 Sources/FeedbackKit/Features/Submit/SubmitViewModel.swift create mode 100644 Tests/FeedbackKitTests/SubmitViewModelTests.swift diff --git a/Sources/FeedbackKit/Features/Submit/SubmitViewModel.swift b/Sources/FeedbackKit/Features/Submit/SubmitViewModel.swift new file mode 100644 index 0000000..9957170 --- /dev/null +++ b/Sources/FeedbackKit/Features/Submit/SubmitViewModel.swift @@ -0,0 +1,38 @@ +import Foundation + +@MainActor +public final class SubmitViewModel: ObservableObject { + @Published public var boardId: String = "" + @Published public var title: String = "" + @Published public var content: String = "" + @Published public private(set) var isSubmitting = false + @Published public private(set) var errorMessage: String? + @Published public var needsSignIn = false + + private let api: FeedbackAPI + private let isSignedIn: () -> Bool + + public init(api: FeedbackAPI, isSignedIn: @escaping () -> Bool) { + self.api = api; self.isSignedIn = isSignedIn + } + + @discardableResult + public func submit() async -> Bool { + errorMessage = nil + guard isSignedIn() else { needsSignIn = true; return false } + guard !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + errorMessage = "Title is required."; return false + } + guard !boardId.isEmpty else { errorMessage = "Pick a board."; return false } + isSubmitting = true + defer { isSubmitting = false } + do { + _ = try await api.submitPost(boardId: boardId, title: title, content: content) + return true + } catch APIError.unauthorized { + needsSignIn = true; return false + } catch { + errorMessage = FeedViewModel.message(for: error); return false + } + } +} diff --git a/Tests/FeedbackKitTests/SubmitViewModelTests.swift b/Tests/FeedbackKitTests/SubmitViewModelTests.swift new file mode 100644 index 0000000..cc28614 --- /dev/null +++ b/Tests/FeedbackKitTests/SubmitViewModelTests.swift @@ -0,0 +1,28 @@ +import XCTest +@testable import FeedbackKit + +@MainActor +final class SubmitViewModelTests: XCTestCase { + func testSubmitSucceedsWhenSignedIn() async { + let api = MockFeedbackAPI() + let vm = SubmitViewModel(api: api, isSignedIn: { true }) + vm.boardId = "b1"; vm.title = "Bug"; vm.content = "Crashes on launch" + let ok = await vm.submit() + XCTAssertTrue(ok) + XCTAssertEqual(api.submitted?.title, "Bug") + } + func testSubmitBlockedWhenSignedOut() async { + let vm = SubmitViewModel(api: MockFeedbackAPI(), isSignedIn: { false }) + vm.boardId = "b1"; vm.title = "Bug" + let ok = await vm.submit() + XCTAssertFalse(ok) + XCTAssertTrue(vm.needsSignIn) + } + func testSubmitValidatesEmptyTitle() async { + let vm = SubmitViewModel(api: MockFeedbackAPI(), isSignedIn: { true }) + vm.boardId = "b1"; vm.title = " " + let ok = await vm.submit() + XCTAssertFalse(ok) + XCTAssertEqual(vm.errorMessage, "Title is required.") + } +} From f6f9b7b78563daf833f842cd71617c9fdbc866e5 Mon Sep 17 00:00:00 2001 From: apeltekci Date: Fri, 29 May 2026 07:35:51 -0700 Subject: [PATCH 11/24] feat(app): Changelog + Help view models Co-Authored-By: Claude Sonnet 4.6 --- .../Changelog/ChangelogViewModel.swift | 16 +++++++++++ .../Features/Help/HelpViewModel.swift | 16 +++++++++++ .../ReadOnlyViewModelsTests.swift | 27 +++++++++++++++++++ 3 files changed, 59 insertions(+) create mode 100644 Sources/FeedbackKit/Features/Changelog/ChangelogViewModel.swift create mode 100644 Sources/FeedbackKit/Features/Help/HelpViewModel.swift create mode 100644 Tests/FeedbackKitTests/ReadOnlyViewModelsTests.swift diff --git a/Sources/FeedbackKit/Features/Changelog/ChangelogViewModel.swift b/Sources/FeedbackKit/Features/Changelog/ChangelogViewModel.swift new file mode 100644 index 0000000..1b3346a --- /dev/null +++ b/Sources/FeedbackKit/Features/Changelog/ChangelogViewModel.swift @@ -0,0 +1,16 @@ +import Foundation + +@MainActor +public final class ChangelogViewModel: ObservableObject { + @Published public private(set) var entries: [ChangelogEntry] = [] + @Published public private(set) var isLoading = false + @Published public private(set) var errorMessage: String? + private let api: FeedbackAPI + public init(api: FeedbackAPI) { self.api = api } + public func load() async { + isLoading = true; errorMessage = nil + do { entries = try await api.listChangelog(cursor: nil).data } + catch { errorMessage = FeedViewModel.message(for: error) } + isLoading = false + } +} diff --git a/Sources/FeedbackKit/Features/Help/HelpViewModel.swift b/Sources/FeedbackKit/Features/Help/HelpViewModel.swift new file mode 100644 index 0000000..ed9b889 --- /dev/null +++ b/Sources/FeedbackKit/Features/Help/HelpViewModel.swift @@ -0,0 +1,16 @@ +import Foundation + +@MainActor +public final class HelpViewModel: ObservableObject { + @Published public private(set) var categories: [HelpCategory] = [] + @Published public private(set) var isLoading = false + @Published public private(set) var errorMessage: String? + private let api: FeedbackAPI + public init(api: FeedbackAPI) { self.api = api } + public func load() async { + isLoading = true; errorMessage = nil + do { categories = try await api.listHelpCategories() } + catch { errorMessage = FeedViewModel.message(for: error) } + isLoading = false + } +} diff --git a/Tests/FeedbackKitTests/ReadOnlyViewModelsTests.swift b/Tests/FeedbackKitTests/ReadOnlyViewModelsTests.swift new file mode 100644 index 0000000..b746422 --- /dev/null +++ b/Tests/FeedbackKitTests/ReadOnlyViewModelsTests.swift @@ -0,0 +1,27 @@ +import XCTest +@testable import FeedbackKit + +@MainActor +final class ReadOnlyViewModelsTests: XCTestCase { + func testChangelogLoads() async { + final class API: MockFeedbackAPI { + override func listChangelog(cursor: String?) async throws -> Page { + Page(data: [ChangelogEntry(id: "cl_1", title: "v1", content: nil, publishedAt: .init())], meta: nil) + } + } + let vm = ChangelogViewModel(api: API()) + await vm.load() + XCTAssertEqual(vm.entries.map(\.id), ["cl_1"]) + } + + func testHelpLoadsCategories() async { + final class API: MockFeedbackAPI { + override func listHelpCategories() async throws -> [HelpCategory] { + [HelpCategory(id: "cat_1", name: "Start", slug: "start", description: nil)] + } + } + let vm = HelpViewModel(api: API()) + await vm.load() + XCTAssertEqual(vm.categories.map(\.slug), ["start"]) + } +} From e3780d578dbd82d14b970cd5761bece631e53539 Mon Sep 17 00:00:00 2001 From: apeltekci Date: Fri, 29 May 2026 07:38:07 -0700 Subject: [PATCH 12/24] feat(app): HTTPFeedbackAPI URLSession client (bearer + error mapping) Co-Authored-By: Claude Sonnet 4.6 --- Sources/FeedbackKit/API/HTTPFeedbackAPI.swift | 150 ++++++++++++++++++ Sources/FeedbackKit/Config/AppConfig.swift | 9 ++ .../HTTPFeedbackAPITests.swift | 45 ++++++ 3 files changed, 204 insertions(+) create mode 100644 Sources/FeedbackKit/API/HTTPFeedbackAPI.swift create mode 100644 Sources/FeedbackKit/Config/AppConfig.swift create mode 100644 Tests/FeedbackKitTests/HTTPFeedbackAPITests.swift diff --git a/Sources/FeedbackKit/API/HTTPFeedbackAPI.swift b/Sources/FeedbackKit/API/HTTPFeedbackAPI.swift new file mode 100644 index 0000000..0bd1f57 --- /dev/null +++ b/Sources/FeedbackKit/API/HTTPFeedbackAPI.swift @@ -0,0 +1,150 @@ +import Foundation + +private struct _ErrorBody: Decodable { + struct Inner: Decodable { let code: String? } + let error: Inner? +} + +public final class HTTPFeedbackAPI: FeedbackAPI, @unchecked Sendable { + private let baseURL: URL + private let session: URLSession + private let tokenProvider: @Sendable () -> String? + + public init(baseURL: URL, + session: URLSession = .shared, + tokenProvider: @escaping @Sendable () -> String?) { + self.baseURL = baseURL + self.session = session + self.tokenProvider = tokenProvider + } + + // MARK: - FeedbackAPI + + public func listPosts(boardId: String?, sort: PostSort, cursor: String?) async throws -> Page { + var query: [(String, String)] = [("sort", sort.rawValue)] + if let boardId { query.append(("boardId", boardId)) } + if let cursor { query.append(("cursor", cursor)) } + let req = request(path: "/api/public/v1/posts", method: "GET", query: query) + return try await send(req) + } + + public func getPost(id: String) async throws -> PostDetail { + let req = request(path: "/api/public/v1/posts/\(id)", method: "GET", query: []) + let envelope: Envelope = try await send(req) + return envelope.data + } + + public func listComments(postId: String) async throws -> [Comment] { + let req = request(path: "/api/public/v1/posts/\(postId)/comments", method: "GET", query: []) + let envelope: Envelope<[Comment]> = try await send(req) + return envelope.data + } + + public func listBoards() async throws -> [Board] { + let req = request(path: "/api/public/v1/boards", method: "GET", query: []) + let envelope: Envelope<[Board]> = try await send(req) + return envelope.data + } + + public func listChangelog(cursor: String?) async throws -> Page { + var query: [(String, String)] = [] + if let cursor { query.append(("cursor", cursor)) } + let req = request(path: "/api/public/v1/changelog", method: "GET", query: query) + return try await send(req) + } + + public func listHelpCategories() async throws -> [HelpCategory] { + let req = request(path: "/api/public/v1/help/categories", method: "GET", query: []) + let envelope: Envelope<[HelpCategory]> = try await send(req) + return envelope.data + } + + public func getHelpArticle(slug: String) async throws -> HelpArticle { + let req = request(path: "/api/public/v1/help/articles/\(slug)", method: "GET", query: []) + let envelope: Envelope = try await send(req) + return envelope.data + } + + public func submitPost(boardId: String, title: String, content: String) async throws -> PostSummary { + var req = request(path: "/api/public/v1/posts", method: "POST", query: []) + let body: [String: String] = ["boardId": boardId, "title": title, "content": content] + req.httpBody = try? JSONSerialization.data(withJSONObject: body) + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + let envelope: Envelope = try await send(req) + return envelope.data + } + + public func vote(postId: String) async throws -> VoteResult { + var req = request(path: "/api/public/v1/posts/\(postId)/vote", method: "POST", query: []) + req.httpBody = Data() + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + let envelope: Envelope = try await send(req) + return envelope.data + } + + public func addComment(postId: String, content: String, parentId: String?) async throws -> Comment { + var req = request(path: "/api/public/v1/posts/\(postId)/comments", method: "POST", query: []) + var body: [String: String] = ["content": content] + if let parentId { body["parentId"] = parentId } + req.httpBody = try? JSONSerialization.data(withJSONObject: body) + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + let envelope: Envelope = try await send(req) + return envelope.data + } + + // MARK: - Private helpers + + private func request(path: String, method: String, query: [(String, String)]) -> URLRequest { + var components = URLComponents(url: baseURL.appendingPathComponent(path), resolvingAgainstBaseURL: false)! + // appendingPathComponent may double-encode slashes; rebuild the path directly + components = URLComponents() + components.scheme = baseURL.scheme + components.host = baseURL.host + components.port = baseURL.port + components.path = path + if !query.isEmpty { + components.queryItems = query.map { URLQueryItem(name: $0.0, value: $0.1) } + } + var req = URLRequest(url: components.url!) + req.httpMethod = method + if let token = tokenProvider() { + req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + } + return req + } + + private func send(_ request: URLRequest) async throws -> R { + let (data, response): (Data, URLResponse) + do { + (data, response) = try await session.data(for: request) + } catch { + throw APIError.transport(error.localizedDescription) + } + + guard let http = response as? HTTPURLResponse else { + throw APIError.transport("Non-HTTP response") + } + + let status = http.statusCode + if (200..<300).contains(status) { + do { + return try JSONDecoder.feedback.decode(R.self, from: data) + } catch { + throw APIError.decoding(error.localizedDescription) + } + } + + switch status { + case 401: + throw APIError.unauthorized + case 404: + throw APIError.notFound + case 429: + throw APIError.rateLimited + default: + // Attempt to extract error.code from the response body + let code = (try? JSONDecoder().decode(_ErrorBody.self, from: data))?.error?.code + throw APIError.server(status: status, code: code) + } + } +} diff --git a/Sources/FeedbackKit/Config/AppConfig.swift b/Sources/FeedbackKit/Config/AppConfig.swift new file mode 100644 index 0000000..461e58e --- /dev/null +++ b/Sources/FeedbackKit/Config/AppConfig.swift @@ -0,0 +1,9 @@ +import Foundation + +public struct AppConfig: Sendable { + public let instanceURL: URL + + public init(instanceURL: URL) { + self.instanceURL = instanceURL + } +} diff --git a/Tests/FeedbackKitTests/HTTPFeedbackAPITests.swift b/Tests/FeedbackKitTests/HTTPFeedbackAPITests.swift new file mode 100644 index 0000000..c20f73e --- /dev/null +++ b/Tests/FeedbackKitTests/HTTPFeedbackAPITests.swift @@ -0,0 +1,45 @@ +import XCTest +@testable import FeedbackKit + +final class StubURLProtocol: URLProtocol { + nonisolated(unsafe) static var handler: ((URLRequest) -> (HTTPURLResponse, Data))? + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for r: URLRequest) -> URLRequest { r } + override func startLoading() { + let (resp, data) = StubURLProtocol.handler!(request) + client?.urlProtocol(self, didReceive: resp, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: data) + client?.urlProtocolDidFinishLoading(self) + } + override func stopLoading() {} +} + +final class HTTPFeedbackAPITests: XCTestCase { + private func makeAPI(token: String? = nil) -> HTTPFeedbackAPI { + let cfg = URLSessionConfiguration.ephemeral + cfg.protocolClasses = [StubURLProtocol.self] + let session = URLSession(configuration: cfg) + return HTTPFeedbackAPI(baseURL: URL(string: "https://fb.example.com")!, + session: session, tokenProvider: { token }) + } + + func testListPostsParsesEnvelope() async throws { + StubURLProtocol.handler = { req in + XCTAssertEqual(req.url?.path, "/api/public/v1/posts") + let body = #"{"data":[{"id":"post_1","title":"A","voteCount":2,"statusId":null,"boardId":"b1","createdAt":"2026-01-01T00:00:00.000Z","hasVoted":false}],"meta":{"pagination":{"cursor":null,"hasMore":false}}}"# + return (HTTPURLResponse(url: req.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!, body.data(using: .utf8)!) + } + let page = try await makeAPI().listPosts(boardId: nil, sort: .newest, cursor: nil) + XCTAssertEqual(page.data.first?.id, "post_1") + } + + func testVoteSendsBearerAndMapsUnauthorized() async { + StubURLProtocol.handler = { req in + XCTAssertEqual(req.value(forHTTPHeaderField: "Authorization"), "Bearer tok") + return (HTTPURLResponse(url: req.url!, statusCode: 401, httpVersion: nil, headerFields: nil)!, + #"{"error":{"code":"UNAUTHORIZED","message":"x"}}"#.data(using: .utf8)!) + } + do { _ = try await makeAPI(token: "tok").vote(postId: "post_1"); XCTFail("expected throw") } + catch { XCTAssertEqual(error as? APIError, .unauthorized) } + } +} From 73b90f54c38dc3fbbcdcb96bfbe49334f0498b13 Mon Sep 17 00:00:00 2001 From: apeltekci Date: Fri, 29 May 2026 07:40:19 -0700 Subject: [PATCH 13/24] feat(app): HTTPAuthService (better-auth email-OTP) --- .../FeedbackKit/Auth/HTTPAuthService.swift | 36 +++++++++++++++++++ .../HTTPAuthServiceTests.swift | 19 ++++++++++ 2 files changed, 55 insertions(+) create mode 100644 Sources/FeedbackKit/Auth/HTTPAuthService.swift create mode 100644 Tests/FeedbackKitTests/HTTPAuthServiceTests.swift diff --git a/Sources/FeedbackKit/Auth/HTTPAuthService.swift b/Sources/FeedbackKit/Auth/HTTPAuthService.swift new file mode 100644 index 0000000..a3d618a --- /dev/null +++ b/Sources/FeedbackKit/Auth/HTTPAuthService.swift @@ -0,0 +1,36 @@ +import Foundation + +// NOTE: The endpoint paths below (/api/auth/email-otp/send-verification-otp and +// /api/auth/sign-in/email-otp) and the "token" field in the sign-in response are +// assumptions based on the better-auth `emailOTP` plugin convention. Confirm these +// against a live instance before shipping — AuthService/AuthStore interfaces won't change. +public final class HTTPAuthService: AuthService, @unchecked Sendable { + private let baseURL: URL + private let session: URLSession + public init(baseURL: URL, session: URLSession = .shared) { self.baseURL = baseURL; self.session = session } + + public func sendOTP(email: String) async throws { + _ = try await post("/api/auth/email-otp/send-verification-otp", body: ["email": email, "type": "sign-in"]) + } + + public func verifyOTP(email: String, code: String) async throws -> String { + let data = try await post("/api/auth/sign-in/email-otp", body: ["email": email, "otp": code]) + struct R: Decodable { let token: String } + guard let token = try? JSONDecoder().decode(R.self, from: data).token else { + throw APIError.decoding("No token in sign-in response") + } + return token + } + + private func post(_ path: String, body: [String: String]) async throws -> Data { + var req = URLRequest(url: baseURL.appendingPathComponent(path)) + req.httpMethod = "POST" + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + req.httpBody = try JSONSerialization.data(withJSONObject: body) + let (data, response) = try await session.data(for: req) + guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else { + throw APIError.server(status: (response as? HTTPURLResponse)?.statusCode ?? -1, code: nil) + } + return data + } +} diff --git a/Tests/FeedbackKitTests/HTTPAuthServiceTests.swift b/Tests/FeedbackKitTests/HTTPAuthServiceTests.swift new file mode 100644 index 0000000..03c943b --- /dev/null +++ b/Tests/FeedbackKitTests/HTTPAuthServiceTests.swift @@ -0,0 +1,19 @@ +import XCTest +@testable import FeedbackKit + +final class HTTPAuthServiceTests: XCTestCase { + private func make() -> HTTPAuthService { + let cfg = URLSessionConfiguration.ephemeral + cfg.protocolClasses = [StubURLProtocol.self] + return HTTPAuthService(baseURL: URL(string: "https://fb.example.com")!, session: URLSession(configuration: cfg)) + } + func testVerifyReturnsToken() async throws { + StubURLProtocol.handler = { req in + XCTAssertEqual(req.url?.path, "/api/auth/sign-in/email-otp") + return (HTTPURLResponse(url: req.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!, + #"{"token":"sess_123","user":{"id":"u1"}}"#.data(using: .utf8)!) + } + let token = try await make().verifyOTP(email: "v@x.com", code: "123456") + XCTAssertEqual(token, "sess_123") + } +} From ab55058f95ab4c0e1d21a2302ee0699dbc58b9ec Mon Sep 17 00:00:00 2001 From: apeltekci Date: Fri, 29 May 2026 07:44:37 -0700 Subject: [PATCH 14/24] feat(app): offline content cache (read-only fallback) Co-Authored-By: Claude Sonnet 4.6 --- Sources/FeedbackKit/Cache/ContentCache.swift | 34 +++++++++++++++++++ .../Changelog/ChangelogViewModel.swift | 21 ++++++++++-- .../Features/Feed/FeedViewModel.swift | 16 +++++++-- .../Features/Help/HelpViewModel.swift | 21 ++++++++++-- .../FeedbackKitTests/ContentCacheTests.swift | 18 ++++++++++ .../FeedbackKitTests/FeedViewModelTests.swift | 24 +++++++++++-- 6 files changed, 124 insertions(+), 10 deletions(-) create mode 100644 Sources/FeedbackKit/Cache/ContentCache.swift create mode 100644 Tests/FeedbackKitTests/ContentCacheTests.swift diff --git a/Sources/FeedbackKit/Cache/ContentCache.swift b/Sources/FeedbackKit/Cache/ContentCache.swift new file mode 100644 index 0000000..ff2bf9d --- /dev/null +++ b/Sources/FeedbackKit/Cache/ContentCache.swift @@ -0,0 +1,34 @@ +import Foundation + +public struct ContentCache: Sendable { + private let directory: URL + + public init(directory: URL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] + .appendingPathComponent("FeedbackKit")) { + self.directory = directory + try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + } + + public func save(_ value: T, as key: String) throws { + let data = try Self.encoder.encode(value) + try data.write(to: directory.appendingPathComponent("\(key).json"), options: .atomic) + } + + public func load(_ key: String, as type: T.Type) throws -> T { + let data = try Data(contentsOf: directory.appendingPathComponent("\(key).json")) + return try JSONDecoder.feedback.decode(T.self, from: data) + } + + // Encoder whose date strategy matches JSONDecoder.feedback (ISO-8601 with fractional seconds). + private static let encoder: JSONEncoder = { + let encoder = JSONEncoder() + encoder.keyEncodingStrategy = .convertToSnakeCase + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + encoder.dateEncodingStrategy = .custom { date, enc in + var container = enc.singleValueContainer() + try container.encode(formatter.string(from: date)) + } + return encoder + }() +} diff --git a/Sources/FeedbackKit/Features/Changelog/ChangelogViewModel.swift b/Sources/FeedbackKit/Features/Changelog/ChangelogViewModel.swift index 1b3346a..fe1b041 100644 --- a/Sources/FeedbackKit/Features/Changelog/ChangelogViewModel.swift +++ b/Sources/FeedbackKit/Features/Changelog/ChangelogViewModel.swift @@ -6,11 +6,26 @@ public final class ChangelogViewModel: ObservableObject { @Published public private(set) var isLoading = false @Published public private(set) var errorMessage: String? private let api: FeedbackAPI - public init(api: FeedbackAPI) { self.api = api } + private let cache: ContentCache + private let cacheKey = "changelog" + public init(api: FeedbackAPI, cache: ContentCache = ContentCache()) { + self.api = api + self.cache = cache + } public func load() async { isLoading = true; errorMessage = nil - do { entries = try await api.listChangelog(cursor: nil).data } - catch { errorMessage = FeedViewModel.message(for: error) } + do { + entries = try await api.listChangelog(cursor: nil).data + try? cache.save(entries, as: cacheKey) + } catch { + if entries.isEmpty { + if let cached = try? cache.load(cacheKey, as: [ChangelogEntry].self) { + entries = cached + } else { + errorMessage = FeedViewModel.message(for: error) + } + } + } isLoading = false } } diff --git a/Sources/FeedbackKit/Features/Feed/FeedViewModel.swift b/Sources/FeedbackKit/Features/Feed/FeedViewModel.swift index 4316edd..cc0b209 100644 --- a/Sources/FeedbackKit/Features/Feed/FeedViewModel.swift +++ b/Sources/FeedbackKit/Features/Feed/FeedViewModel.swift @@ -7,10 +7,15 @@ public final class FeedViewModel: ObservableObject { @Published public private(set) var errorMessage: String? private let api: FeedbackAPI + private let cache: ContentCache + private let cacheKey = "feed" public var boardId: String? public var sort: PostSort = .newest - public init(api: FeedbackAPI) { self.api = api } + public init(api: FeedbackAPI, cache: ContentCache = ContentCache()) { + self.api = api + self.cache = cache + } public func load() async { isLoading = true @@ -18,8 +23,15 @@ public final class FeedViewModel: ObservableObject { do { let page = try await api.listPosts(boardId: boardId, sort: sort, cursor: nil) posts = page.data + try? cache.save(posts, as: cacheKey) } catch { - errorMessage = Self.message(for: error) + if posts.isEmpty { + if let cached = try? cache.load(cacheKey, as: [PostSummary].self) { + posts = cached + } else { + errorMessage = Self.message(for: error) + } + } } isLoading = false } diff --git a/Sources/FeedbackKit/Features/Help/HelpViewModel.swift b/Sources/FeedbackKit/Features/Help/HelpViewModel.swift index ed9b889..6486564 100644 --- a/Sources/FeedbackKit/Features/Help/HelpViewModel.swift +++ b/Sources/FeedbackKit/Features/Help/HelpViewModel.swift @@ -6,11 +6,26 @@ public final class HelpViewModel: ObservableObject { @Published public private(set) var isLoading = false @Published public private(set) var errorMessage: String? private let api: FeedbackAPI - public init(api: FeedbackAPI) { self.api = api } + private let cache: ContentCache + private let cacheKey = "help_categories" + public init(api: FeedbackAPI, cache: ContentCache = ContentCache()) { + self.api = api + self.cache = cache + } public func load() async { isLoading = true; errorMessage = nil - do { categories = try await api.listHelpCategories() } - catch { errorMessage = FeedViewModel.message(for: error) } + do { + categories = try await api.listHelpCategories() + try? cache.save(categories, as: cacheKey) + } catch { + if categories.isEmpty { + if let cached = try? cache.load(cacheKey, as: [HelpCategory].self) { + categories = cached + } else { + errorMessage = FeedViewModel.message(for: error) + } + } + } isLoading = false } } diff --git a/Tests/FeedbackKitTests/ContentCacheTests.swift b/Tests/FeedbackKitTests/ContentCacheTests.swift new file mode 100644 index 0000000..f6d0734 --- /dev/null +++ b/Tests/FeedbackKitTests/ContentCacheTests.swift @@ -0,0 +1,18 @@ +import XCTest +@testable import FeedbackKit + +final class ContentCacheTests: XCTestCase { + func testRoundTripsPosts() throws { + let dir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let cache = ContentCache(directory: dir) + // Use a date with millisecond precision so the ISO-8601 encode/decode round-trip is exact. + let posts = [PostSummary(id: "post_1", title: "A", voteCount: 1, statusId: nil, boardId: "b1", createdAt: Date(timeIntervalSince1970: 1_700_000_000.123), hasVoted: false)] + try cache.save(posts, as: "feed") + let loaded: [PostSummary] = try cache.load("feed", as: [PostSummary].self) + XCTAssertEqual(loaded, posts) + } + func testLoadMissingThrows() { + let cache = ContentCache(directory: FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)) + XCTAssertThrowsError(try cache.load("nope", as: [PostSummary].self)) + } +} diff --git a/Tests/FeedbackKitTests/FeedViewModelTests.swift b/Tests/FeedbackKitTests/FeedViewModelTests.swift index 8dbfdf3..3b53a06 100644 --- a/Tests/FeedbackKitTests/FeedViewModelTests.swift +++ b/Tests/FeedbackKitTests/FeedViewModelTests.swift @@ -6,7 +6,7 @@ final class FeedViewModelTests: XCTestCase { func testLoadPopulatesPostsAndClearsLoading() async { let api = MockFeedbackAPI() api.posts = [PostSummary(id: "post_1", title: "A", voteCount: 5, statusId: nil, boardId: "b1", createdAt: .init(), hasVoted: false)] - let vm = FeedViewModel(api: api) + let vm = FeedViewModel(api: api, cache: ContentCache(directory: FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString))) await vm.load() XCTAssertEqual(vm.posts.map(\.id), ["post_1"]) XCTAssertFalse(vm.isLoading) @@ -19,10 +19,30 @@ final class FeedViewModelTests: XCTestCase { throw APIError.transport("offline") } } - let vm = FeedViewModel(api: FailingAPI()) + let emptyCache = ContentCache(directory: FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)) + let vm = FeedViewModel(api: FailingAPI(), cache: emptyCache) await vm.load() XCTAssertTrue(vm.posts.isEmpty) XCTAssertNotNil(vm.errorMessage) XCTAssertFalse(vm.isLoading) } + + func testCacheFallbackYieldsCachedPostsAndNoError() async throws { + // Pre-populate a temp cache with one post + let dir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let cache = ContentCache(directory: dir) + let cachedPost = PostSummary(id: "cached_1", title: "Cached", voteCount: 2, statusId: nil, boardId: "b1", createdAt: Date(timeIntervalSince1970: 0), hasVoted: false) + try cache.save([cachedPost], as: "feed") + + final class FailingAPI: MockFeedbackAPI { + override func listPosts(boardId: String?, sort: PostSort, cursor: String?) async throws -> Page { + throw APIError.transport("offline") + } + } + let vm = FeedViewModel(api: FailingAPI(), cache: cache) + await vm.load() + XCTAssertEqual(vm.posts.map(\.id), ["cached_1"]) + XCTAssertNil(vm.errorMessage) + XCTAssertFalse(vm.isLoading) + } } From 470260529da42711d8c1cf365bcd1301e0360419 Mon Sep 17 00:00:00 2001 From: apeltekci Date: Fri, 29 May 2026 07:50:23 -0700 Subject: [PATCH 15/24] feat(app): SwiftUI 4-tab FeedbackPortalApp shell Co-Authored-By: Claude Sonnet 4.6 --- App/FeedbackPortalApp/AccountTabView.swift | 47 ++++++ App/FeedbackPortalApp/ChangelogTabView.swift | 77 +++++++++ App/FeedbackPortalApp/Environment.swift | 27 +++ App/FeedbackPortalApp/FeedTabView.swift | 108 ++++++++++++ App/FeedbackPortalApp/FeedbackPortalApp.swift | 15 ++ App/FeedbackPortalApp/HelpTabView.swift | 72 ++++++++ App/FeedbackPortalApp/PostDetailView.swift | 154 ++++++++++++++++++ App/FeedbackPortalApp/RootTabView.swift | 31 ++++ App/FeedbackPortalApp/SignInSheet.swift | 110 +++++++++++++ App/FeedbackPortalApp/SubmitView.swift | 95 +++++++++++ project.yml | 54 ++++++ 11 files changed, 790 insertions(+) create mode 100644 App/FeedbackPortalApp/AccountTabView.swift create mode 100644 App/FeedbackPortalApp/ChangelogTabView.swift create mode 100644 App/FeedbackPortalApp/Environment.swift create mode 100644 App/FeedbackPortalApp/FeedTabView.swift create mode 100644 App/FeedbackPortalApp/FeedbackPortalApp.swift create mode 100644 App/FeedbackPortalApp/HelpTabView.swift create mode 100644 App/FeedbackPortalApp/PostDetailView.swift create mode 100644 App/FeedbackPortalApp/RootTabView.swift create mode 100644 App/FeedbackPortalApp/SignInSheet.swift create mode 100644 App/FeedbackPortalApp/SubmitView.swift diff --git a/App/FeedbackPortalApp/AccountTabView.swift b/App/FeedbackPortalApp/AccountTabView.swift new file mode 100644 index 0000000..b58c393 --- /dev/null +++ b/App/FeedbackPortalApp/AccountTabView.swift @@ -0,0 +1,47 @@ +import FeedbackKit +import SwiftUI + +struct AccountTabView: View { + @EnvironmentObject private var auth: AuthStore + + @State private var isShowingSignIn = false + + var body: some View { + NavigationStack { + List { + if auth.isSignedIn { + Section { + Label("Signed in", systemImage: "checkmark.seal.fill") + .foregroundStyle(.green) + } + + Section { + Button(role: .destructive) { + auth.signOut() + } label: { + Label("Sign Out", systemImage: "rectangle.portrait.and.arrow.right") + } + } + } else { + Section { + Label("Not signed in", systemImage: "person.crop.circle.badge.xmark") + .foregroundStyle(.secondary) + } + + Section { + Button { + isShowingSignIn = true + } label: { + Label("Sign In", systemImage: "person.crop.circle.badge.checkmark") + } + } + } + } + .listStyle(.insetGrouped) + .navigationTitle("Account") + .sheet(isPresented: $isShowingSignIn) { + SignInSheet() + } + } + } +} diff --git a/App/FeedbackPortalApp/ChangelogTabView.swift b/App/FeedbackPortalApp/ChangelogTabView.swift new file mode 100644 index 0000000..7e79119 --- /dev/null +++ b/App/FeedbackPortalApp/ChangelogTabView.swift @@ -0,0 +1,77 @@ +import FeedbackKit +import SwiftUI + +struct ChangelogTabView: View { + @EnvironmentObject private var env: AppEnvironment + + @State private var vm: ChangelogViewModel? + + var body: some View { + NavigationStack { + Group { + if let vm { + ChangelogListView(vm: vm) + } else { + ProgressView() + } + } + .navigationTitle("Changelog") + } + .task { + let model = ChangelogViewModel(api: env.api) + vm = model + await model.load() + } + } +} + +private struct ChangelogListView: View { + @ObservedObject var vm: ChangelogViewModel + + var body: some View { + List { + if vm.isLoading && vm.entries.isEmpty { + ProgressView() + .frame(maxWidth: .infinity) + .listRowSeparator(.hidden) + } else if let error = vm.errorMessage, vm.entries.isEmpty { + ContentUnavailableView( + "Couldn't Load", + systemImage: "exclamationmark.triangle", + description: Text(error) + ) + .listRowSeparator(.hidden) + } else if vm.entries.isEmpty { + ContentUnavailableView( + "No Entries Yet", + systemImage: "clock.arrow.circlepath", + description: Text("Check back for updates.") + ) + .listRowSeparator(.hidden) + } else { + ForEach(vm.entries) { entry in + VStack(alignment: .leading, spacing: 6) { + Text(entry.title) + .font(.headline) + if let publishedAt = entry.publishedAt { + Text(publishedAt, style: .date) + .font(.caption) + .foregroundStyle(.secondary) + } + if let content = entry.content, !content.isEmpty { + Text(content) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(3) + } + } + .padding(.vertical, 4) + } + } + } + .listStyle(.plain) + .refreshable { + await vm.load() + } + } +} diff --git a/App/FeedbackPortalApp/Environment.swift b/App/FeedbackPortalApp/Environment.swift new file mode 100644 index 0000000..9985107 --- /dev/null +++ b/App/FeedbackPortalApp/Environment.swift @@ -0,0 +1,27 @@ +import FeedbackKit +import Foundation + +@MainActor +final class AppEnvironment: ObservableObject { + let api: FeedbackAPI + let auth: AuthStore + + private let tokenStore: KeychainTokenStore + + init() { + let raw = Bundle.main.object(forInfoDictionaryKey: "FEEDBACK_INSTANCE_URL") as? String + ?? "http://localhost:3000" + let instanceURL = URL(string: raw) ?? URL(string: "http://localhost:3000")! + + let store = KeychainTokenStore() + let authService = HTTPAuthService(baseURL: instanceURL) + let authStore = AuthStore(service: authService, tokenStore: store) + + self.tokenStore = store + self.auth = authStore + self.api = HTTPFeedbackAPI( + baseURL: instanceURL, + tokenProvider: { store.token } + ) + } +} diff --git a/App/FeedbackPortalApp/FeedTabView.swift b/App/FeedbackPortalApp/FeedTabView.swift new file mode 100644 index 0000000..a3dd306 --- /dev/null +++ b/App/FeedbackPortalApp/FeedTabView.swift @@ -0,0 +1,108 @@ +import FeedbackKit +import SwiftUI + +struct FeedTabView: View { + @EnvironmentObject private var env: AppEnvironment + @EnvironmentObject private var auth: AuthStore + + @State private var vm: FeedViewModel? + @State private var isShowingSubmit = false + + var body: some View { + NavigationStack { + Group { + if let vm { + FeedListView(vm: vm, isShowingSubmit: $isShowingSubmit) + } else { + ProgressView() + } + } + .navigationTitle("Feedback") + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button { + isShowingSubmit = true + } label: { + Image(systemName: "plus") + } + } + } + .sheet(isPresented: $isShowingSubmit) { + if let vm { + SubmitView(api: env.api, auth: auth) + } + } + } + .task { + let model = FeedViewModel(api: env.api) + vm = model + await model.load() + } + } +} + +private struct FeedListView: View { + @ObservedObject var vm: FeedViewModel + @Binding var isShowingSubmit: Bool + + var body: some View { + List { + if vm.isLoading && vm.posts.isEmpty { + ProgressView() + .frame(maxWidth: .infinity) + .listRowSeparator(.hidden) + } else if let error = vm.errorMessage, vm.posts.isEmpty { + ContentUnavailableView( + "Couldn't Load", + systemImage: "exclamationmark.triangle", + description: Text(error) + ) + .listRowSeparator(.hidden) + } else if vm.posts.isEmpty { + ContentUnavailableView( + "No Posts Yet", + systemImage: "bubble.left", + description: Text("Be the first to submit feedback.") + ) + .listRowSeparator(.hidden) + } else { + ForEach(vm.posts) { post in + NavigationLink(value: post.id) { + PostRowView(post: post) + } + } + } + } + .listStyle(.plain) + .refreshable { + await vm.load() + } + .navigationDestination(for: String.self) { postId in + PostDetailView(postId: postId) + } + } +} + +private struct PostRowView: View { + let post: PostSummary + + var body: some View { + HStack(spacing: 12) { + VStack(spacing: 2) { + Image(systemName: post.hasVoted ? "arrowtriangle.up.fill" : "arrowtriangle.up") + .foregroundStyle(post.hasVoted ? .blue : .secondary) + Text("\(post.voteCount)") + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + } + .frame(width: 36) + + Text(post.title) + .font(.body) + .lineLimit(2) + + Spacer() + } + .padding(.vertical, 4) + } +} diff --git a/App/FeedbackPortalApp/FeedbackPortalApp.swift b/App/FeedbackPortalApp/FeedbackPortalApp.swift new file mode 100644 index 0000000..ad1b599 --- /dev/null +++ b/App/FeedbackPortalApp/FeedbackPortalApp.swift @@ -0,0 +1,15 @@ +import FeedbackKit +import SwiftUI + +@main +struct FeedbackPortalApp: App { + @StateObject private var env = AppEnvironment() + + var body: some Scene { + WindowGroup { + RootTabView() + .environmentObject(env) + .environmentObject(env.auth) + } + } +} diff --git a/App/FeedbackPortalApp/HelpTabView.swift b/App/FeedbackPortalApp/HelpTabView.swift new file mode 100644 index 0000000..848eaa8 --- /dev/null +++ b/App/FeedbackPortalApp/HelpTabView.swift @@ -0,0 +1,72 @@ +import FeedbackKit +import SwiftUI + +struct HelpTabView: View { + @EnvironmentObject private var env: AppEnvironment + + @State private var vm: HelpViewModel? + + var body: some View { + NavigationStack { + Group { + if let vm { + HelpListView(vm: vm) + } else { + ProgressView() + } + } + .navigationTitle("Help") + } + .task { + let model = HelpViewModel(api: env.api) + vm = model + await model.load() + } + } +} + +private struct HelpListView: View { + @ObservedObject var vm: HelpViewModel + + var body: some View { + List { + if vm.isLoading && vm.categories.isEmpty { + ProgressView() + .frame(maxWidth: .infinity) + .listRowSeparator(.hidden) + } else if let error = vm.errorMessage, vm.categories.isEmpty { + ContentUnavailableView( + "Couldn't Load", + systemImage: "exclamationmark.triangle", + description: Text(error) + ) + .listRowSeparator(.hidden) + } else if vm.categories.isEmpty { + ContentUnavailableView( + "No Categories", + systemImage: "questionmark.circle", + description: Text("No help content available yet.") + ) + .listRowSeparator(.hidden) + } else { + ForEach(vm.categories) { category in + VStack(alignment: .leading, spacing: 4) { + Text(category.name) + .font(.headline) + if let description = category.description, !description.isEmpty { + Text(description) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(2) + } + } + .padding(.vertical, 4) + } + } + } + .listStyle(.plain) + .refreshable { + await vm.load() + } + } +} diff --git a/App/FeedbackPortalApp/PostDetailView.swift b/App/FeedbackPortalApp/PostDetailView.swift new file mode 100644 index 0000000..77fbe2b --- /dev/null +++ b/App/FeedbackPortalApp/PostDetailView.swift @@ -0,0 +1,154 @@ +import FeedbackKit +import SwiftUI + +struct PostDetailView: View { + let postId: String + + @EnvironmentObject private var env: AppEnvironment + @EnvironmentObject private var auth: AuthStore + + @State private var vm: PostDetailViewModel? + @State private var commentText = "" + @State private var isShowingSignIn = false + + var body: some View { + Group { + if let vm { + PostDetailContent( + vm: vm, + commentText: $commentText, + isShowingSignIn: $isShowingSignIn + ) + } else { + ProgressView() + } + } + .navigationTitle("Post") + .navigationBarTitleDisplayMode(.inline) + .sheet(isPresented: $isShowingSignIn) { + SignInSheet() + } + .task { + let model = PostDetailViewModel( + postId: postId, + api: env.api, + isSignedIn: { [auth] in auth.isSignedIn } + ) + vm = model + await model.load() + } + .onChange(of: vm?.needsSignIn) { _, needsSignIn in + if needsSignIn == true { + isShowingSignIn = true + } + } + } +} + +private struct PostDetailContent: View { + @ObservedObject var vm: PostDetailViewModel + @Binding var commentText: String + @Binding var isShowingSignIn: Bool + + var body: some View { + List { + if vm.isLoading && vm.post == nil { + ProgressView() + .frame(maxWidth: .infinity) + .listRowSeparator(.hidden) + } else if let error = vm.errorMessage, vm.post == nil { + ContentUnavailableView( + "Couldn't Load", + systemImage: "exclamationmark.triangle", + description: Text(error) + ) + .listRowSeparator(.hidden) + } else if let post = vm.post { + Section { + VStack(alignment: .leading, spacing: 12) { + Text(post.title) + .font(.headline) + + if !post.content.isEmpty { + Text(post.content) + .font(.body) + .foregroundStyle(.secondary) + } + + Button { + Task { await vm.toggleVote() } + } label: { + Label( + "\(post.voteCount) vote\(post.voteCount == 1 ? "" : "s")", + systemImage: post.hasVoted ? "arrowtriangle.up.fill" : "arrowtriangle.up" + ) + .foregroundStyle(post.hasVoted ? .blue : .primary) + } + .buttonStyle(.borderless) + } + .padding(.vertical, 4) + } + + Section("Comments") { + HStack(alignment: .top) { + TextField("Add a comment…", text: $commentText, axis: .vertical) + .lineLimit(3...6) + + Button { + let text = commentText + commentText = "" + Task { await vm.addComment(text) } + } label: { + Image(systemName: "paperplane.fill") + } + .buttonStyle(.borderless) + .disabled(commentText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + + if vm.comments.isEmpty && !vm.isLoading { + Text("No comments yet.") + .foregroundStyle(.secondary) + .font(.subheadline) + } else { + ForEach(vm.comments) { comment in + CommentRowView(comment: comment) + } + } + } + } + } + .listStyle(.insetGrouped) + .refreshable { + await vm.load() + } + } +} + +private struct CommentRowView: View { + let comment: Comment + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(comment.authorName) + .font(.caption.bold()) + Spacer() + Text(comment.createdAt, style: .relative) + .font(.caption) + .foregroundStyle(.secondary) + } + Text(comment.content) + .font(.subheadline) + + if !comment.replies.isEmpty { + VStack(alignment: .leading, spacing: 4) { + ForEach(comment.replies) { reply in + CommentRowView(comment: reply) + .padding(.leading, 16) + } + } + } + } + .padding(.vertical, 2) + } +} diff --git a/App/FeedbackPortalApp/RootTabView.swift b/App/FeedbackPortalApp/RootTabView.swift new file mode 100644 index 0000000..5f7e8b3 --- /dev/null +++ b/App/FeedbackPortalApp/RootTabView.swift @@ -0,0 +1,31 @@ +import FeedbackKit +import SwiftUI + +struct RootTabView: View { + @EnvironmentObject private var env: AppEnvironment + @EnvironmentObject private var auth: AuthStore + + var body: some View { + TabView { + FeedTabView() + .tabItem { + Label("Feedback", systemImage: "bubble.left.and.bubble.right") + } + + ChangelogTabView() + .tabItem { + Label("Changelog", systemImage: "clock.arrow.circlepath") + } + + HelpTabView() + .tabItem { + Label("Help", systemImage: "questionmark.circle") + } + + AccountTabView() + .tabItem { + Label("Account", systemImage: "person.circle") + } + } + } +} diff --git a/App/FeedbackPortalApp/SignInSheet.swift b/App/FeedbackPortalApp/SignInSheet.swift new file mode 100644 index 0000000..1aa6968 --- /dev/null +++ b/App/FeedbackPortalApp/SignInSheet.swift @@ -0,0 +1,110 @@ +import FeedbackKit +import SwiftUI + +struct SignInSheet: View { + @EnvironmentObject private var auth: AuthStore + @Environment(\.dismiss) private var dismiss + + @State private var email = "" + @State private var code = "" + @State private var codeSent = false + @State private var isSending = false + + var body: some View { + NavigationStack { + Form { + Section { + TextField("Email", text: $email) + .keyboardType(.emailAddress) + .textContentType(.emailAddress) + .autocapitalization(.none) + .autocorrectionDisabled() + .disabled(codeSent) + } + + if codeSent { + Section { + TextField("6-digit code", text: $code) + .keyboardType(.numberPad) + .textContentType(.oneTimeCode) + } + } + + if let error = auth.errorMessage { + Section { + Text(error) + .foregroundStyle(.red) + .font(.subheadline) + } + } + + Section { + if !codeSent { + Button { + Task { await sendCode() } + } label: { + if isSending { + ProgressView() + .frame(maxWidth: .infinity) + } else { + Text("Send Code") + .frame(maxWidth: .infinity) + } + } + .disabled(email.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isSending) + } else { + Button { + Task { await verify() } + } label: { + if isSending { + ProgressView() + .frame(maxWidth: .infinity) + } else { + Text("Verify") + .frame(maxWidth: .infinity) + } + } + .disabled(code.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isSending) + + Button("Resend Code") { + code = "" + codeSent = false + } + .buttonStyle(.borderless) + .frame(maxWidth: .infinity) + .foregroundStyle(.secondary) + } + } + } + .navigationTitle("Sign In") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + } + } + .onChange(of: auth.isSignedIn) { _, isSignedIn in + if isSignedIn { + dismiss() + } + } + } + + private func sendCode() async { + isSending = true + do { + try await auth.requestCode(email: email) + codeSent = true + } catch { + // errorMessage surfaced through auth.errorMessage if set by AuthStore + } + isSending = false + } + + private func verify() async { + isSending = true + await auth.verify(email: email, code: code) + isSending = false + } +} diff --git a/App/FeedbackPortalApp/SubmitView.swift b/App/FeedbackPortalApp/SubmitView.swift new file mode 100644 index 0000000..0326b7f --- /dev/null +++ b/App/FeedbackPortalApp/SubmitView.swift @@ -0,0 +1,95 @@ +import FeedbackKit +import SwiftUI + +struct SubmitView: View { + let api: FeedbackAPI + let auth: AuthStore + + @Environment(\.dismiss) private var dismiss + + @State private var vm: SubmitViewModel? + @State private var isShowingSignIn = false + + var body: some View { + NavigationStack { + Group { + if let vm { + SubmitFormView(vm: vm, isShowingSignIn: $isShowingSignIn, dismiss: dismiss) + } else { + ProgressView() + } + } + .navigationTitle("New Feedback") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("Cancel") { dismiss() } + } + } + .sheet(isPresented: $isShowingSignIn) { + SignInSheet() + } + } + .task { + let model = SubmitViewModel( + api: api, + isSignedIn: { [auth] in auth.isSignedIn } + ) + vm = model + } + .onChange(of: vm?.needsSignIn) { _, needsSignIn in + if needsSignIn == true { + isShowingSignIn = true + } + } + } +} + +private struct SubmitFormView: View { + @ObservedObject var vm: SubmitViewModel + @Binding var isShowingSignIn: Bool + let dismiss: DismissAction + + var body: some View { + Form { + Section("Board") { + TextField("Board ID", text: $vm.boardId) + .autocorrectionDisabled() + } + + Section("Post") { + TextField("Title", text: $vm.title) + TextField("Description (optional)", text: $vm.content, axis: .vertical) + .lineLimit(4...8) + } + + if let error = vm.errorMessage { + Section { + Text(error) + .foregroundStyle(.red) + .font(.subheadline) + } + } + + Section { + Button { + Task { + let success = await vm.submit() + if success { + dismiss() + } + } + } label: { + if vm.isSubmitting { + ProgressView() + .frame(maxWidth: .infinity) + } else { + Text("Submit") + .frame(maxWidth: .infinity) + } + } + .disabled(vm.isSubmitting) + } + } + } +} diff --git a/project.yml b/project.yml index 4ecb987..59a58f8 100644 --- a/project.yml +++ b/project.yml @@ -56,6 +56,44 @@ targets: PRODUCT_BUNDLE_IDENTIFIER: dev.opencoven.feedback INFOPLIST_FILE: FeedbackApp/Sources/FeedbackApp/Info.plist + FeedbackPortalApp: + type: application + platform: iOS + deploymentTarget: "15.0" + sources: + - path: App/FeedbackPortalApp + excludes: + - "**/*.md" + dependencies: + - package: OpenCovenFeedback + product: FeedbackKit + info: + path: App/FeedbackPortalApp/Info.plist + properties: + CFBundleName: "$(PRODUCT_NAME)" + CFBundleDisplayName: Feedback Portal + CFBundleIdentifier: "$(PRODUCT_BUNDLE_IDENTIFIER)" + CFBundleShortVersionString: "1.0" + CFBundleVersion: "1" + UILaunchStoryboardName: "" + FEEDBACK_INSTANCE_URL: "$(FEEDBACK_INSTANCE_URL)" + UISupportedInterfaceOrientations: + - UIInterfaceOrientationPortrait + - UIInterfaceOrientationLandscapeLeft + - UIInterfaceOrientationLandscapeRight + UISupportedInterfaceOrientations~ipad: + - UIInterfaceOrientationPortrait + - UIInterfaceOrientationPortraitUpsideDown + - UIInterfaceOrientationLandscapeLeft + - UIInterfaceOrientationLandscapeRight + NSAppTransportSecurity: + NSAllowsLocalNetworking: true + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: dev.opencoven.feedbackportal + INFOPLIST_FILE: App/FeedbackPortalApp/Info.plist + FEEDBACK_INSTANCE_URL: "http://localhost:3000" + FeedbackAppTests: type: bundle.unit-test platform: iOS @@ -67,3 +105,19 @@ targets: settings: base: PRODUCT_BUNDLE_IDENTIFIER: dev.opencoven.feedback.tests + +schemes: + FeedbackPortalApp: + build: + targets: + FeedbackPortalApp: all + run: + config: Debug + test: + config: Debug + profile: + config: Release + analyze: + config: Debug + archive: + config: Release From e91b3291493453994874ac4a9e7f49355c254d11 Mon Sep 17 00:00:00 2001 From: apeltekci Date: Fri, 29 May 2026 07:59:36 -0700 Subject: [PATCH 16/24] ci: build FeedbackKit + FeedbackPortalApp for iOS, run FeedbackKit tests --- .github/workflows/ci.yml | 58 +++++++++++++++++++ .swiftlint.yml | 5 +- App/FeedbackPortalApp/Info.plist | 46 +++++++++++++++ .../FeedbackKit/Auth/HTTPAuthService.swift | 4 +- Tests/FeedbackKitTests/AuthStoreTests.swift | 2 +- .../FeedbackKitTests/ContentCacheTests.swift | 2 +- .../FeedbackKitTests/FeedViewModelTests.swift | 2 +- .../HTTPAuthServiceTests.swift | 4 +- .../HTTPFeedbackAPITests.swift | 13 +++-- Tests/FeedbackKitTests/MockFeedbackAPI.swift | 23 ++++++-- Tests/FeedbackKitTests/ModelsTests.swift | 9 +-- .../PostDetailViewModelTests.swift | 2 +- .../ProtocolConformanceTests.swift | 2 +- .../ReadOnlyViewModelsTests.swift | 2 +- Tests/FeedbackKitTests/SmokeTests.swift | 2 +- .../SubmitViewModelTests.swift | 2 +- Tests/FeedbackKitTests/TokenStoreTests.swift | 2 +- project.yml | 12 ---- 18 files changed, 150 insertions(+), 42 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 App/FeedbackPortalApp/Info.plist diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..fdecc7a --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,58 @@ +name: CI + +on: + push: + branches: [main] + pull_request: {} + +jobs: + test: + runs-on: macos-14 + steps: + - uses: actions/checkout@v4 + + - name: Swift version + run: swift --version + + - name: Run tests + run: swift test + + - name: Install SwiftLint + run: brew install swiftlint + + - name: SwiftLint + run: swiftlint lint --strict --config .swiftlint.yml + + ios-build: + runs-on: macos-15 + steps: + - uses: actions/checkout@v4 + + - uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: latest-stable + + - name: Xcode version + run: xcodebuild -version + + - name: Build FeedbackKit for iOS Simulator (SPM) + run: | + xcodebuild build \ + -scheme FeedbackKit \ + -destination 'generic/platform=iOS Simulator' \ + CODE_SIGNING_ALLOWED=NO + + - name: Install XcodeGen + run: brew install xcodegen + + - name: Generate Xcode project + run: xcodegen generate + + - name: Build FeedbackPortalApp + run: | + xcodebuild build \ + -project FeedbackApp.xcodeproj \ + -scheme FeedbackPortalApp \ + -sdk iphonesimulator \ + -destination 'generic/platform=iOS Simulator' \ + CODE_SIGNING_ALLOWED=NO diff --git a/.swiftlint.yml b/.swiftlint.yml index 156e3e0..cac181c 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -1,9 +1,10 @@ -included_paths: +included: - FeedbackApp/ - Sources/ - Tests/ + - App/ -excluded_paths: +excluded: - .build/ - DerivedData/ diff --git a/App/FeedbackPortalApp/Info.plist b/App/FeedbackPortalApp/Info.plist new file mode 100644 index 0000000..7d418df --- /dev/null +++ b/App/FeedbackPortalApp/Info.plist @@ -0,0 +1,46 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + Feedback Portal + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + FEEDBACK_INSTANCE_URL + $(FEEDBACK_INSTANCE_URL) + NSAppTransportSecurity + + NSAllowsLocalNetworking + + + UILaunchStoryboardName + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/Sources/FeedbackKit/Auth/HTTPAuthService.swift b/Sources/FeedbackKit/Auth/HTTPAuthService.swift index a3d618a..8ba5f1e 100644 --- a/Sources/FeedbackKit/Auth/HTTPAuthService.swift +++ b/Sources/FeedbackKit/Auth/HTTPAuthService.swift @@ -15,8 +15,8 @@ public final class HTTPAuthService: AuthService, @unchecked Sendable { public func verifyOTP(email: String, code: String) async throws -> String { let data = try await post("/api/auth/sign-in/email-otp", body: ["email": email, "otp": code]) - struct R: Decodable { let token: String } - guard let token = try? JSONDecoder().decode(R.self, from: data).token else { + struct SignInResponse: Decodable { let token: String } + guard let token = try? JSONDecoder().decode(SignInResponse.self, from: data).token else { throw APIError.decoding("No token in sign-in response") } return token diff --git a/Tests/FeedbackKitTests/AuthStoreTests.swift b/Tests/FeedbackKitTests/AuthStoreTests.swift index 7c3816f..02464fe 100644 --- a/Tests/FeedbackKitTests/AuthStoreTests.swift +++ b/Tests/FeedbackKitTests/AuthStoreTests.swift @@ -1,5 +1,5 @@ -import XCTest @testable import FeedbackKit +import XCTest final class StubAuthService: AuthService, @unchecked Sendable { var sentTo: String? diff --git a/Tests/FeedbackKitTests/ContentCacheTests.swift b/Tests/FeedbackKitTests/ContentCacheTests.swift index f6d0734..9b8c979 100644 --- a/Tests/FeedbackKitTests/ContentCacheTests.swift +++ b/Tests/FeedbackKitTests/ContentCacheTests.swift @@ -1,5 +1,5 @@ -import XCTest @testable import FeedbackKit +import XCTest final class ContentCacheTests: XCTestCase { func testRoundTripsPosts() throws { diff --git a/Tests/FeedbackKitTests/FeedViewModelTests.swift b/Tests/FeedbackKitTests/FeedViewModelTests.swift index 3b53a06..e3ef073 100644 --- a/Tests/FeedbackKitTests/FeedViewModelTests.swift +++ b/Tests/FeedbackKitTests/FeedViewModelTests.swift @@ -1,5 +1,5 @@ -import XCTest @testable import FeedbackKit +import XCTest @MainActor final class FeedViewModelTests: XCTestCase { diff --git a/Tests/FeedbackKitTests/HTTPAuthServiceTests.swift b/Tests/FeedbackKitTests/HTTPAuthServiceTests.swift index 03c943b..6dc299c 100644 --- a/Tests/FeedbackKitTests/HTTPAuthServiceTests.swift +++ b/Tests/FeedbackKitTests/HTTPAuthServiceTests.swift @@ -1,5 +1,5 @@ -import XCTest @testable import FeedbackKit +import XCTest final class HTTPAuthServiceTests: XCTestCase { private func make() -> HTTPAuthService { @@ -11,7 +11,7 @@ final class HTTPAuthServiceTests: XCTestCase { StubURLProtocol.handler = { req in XCTAssertEqual(req.url?.path, "/api/auth/sign-in/email-otp") return (HTTPURLResponse(url: req.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!, - #"{"token":"sess_123","user":{"id":"u1"}}"#.data(using: .utf8)!) + Data(#"{"token":"sess_123","user":{"id":"u1"}}"#.utf8)) } let token = try await make().verifyOTP(email: "v@x.com", code: "123456") XCTAssertEqual(token, "sess_123") diff --git a/Tests/FeedbackKitTests/HTTPFeedbackAPITests.swift b/Tests/FeedbackKitTests/HTTPFeedbackAPITests.swift index c20f73e..0fbb4a9 100644 --- a/Tests/FeedbackKitTests/HTTPFeedbackAPITests.swift +++ b/Tests/FeedbackKitTests/HTTPFeedbackAPITests.swift @@ -1,7 +1,7 @@ -import XCTest @testable import FeedbackKit +import XCTest -final class StubURLProtocol: URLProtocol { +class StubURLProtocol: URLProtocol { nonisolated(unsafe) static var handler: ((URLRequest) -> (HTTPURLResponse, Data))? override class func canInit(with request: URLRequest) -> Bool { true } override class func canonicalRequest(for r: URLRequest) -> URLRequest { r } @@ -27,7 +27,7 @@ final class HTTPFeedbackAPITests: XCTestCase { StubURLProtocol.handler = { req in XCTAssertEqual(req.url?.path, "/api/public/v1/posts") let body = #"{"data":[{"id":"post_1","title":"A","voteCount":2,"statusId":null,"boardId":"b1","createdAt":"2026-01-01T00:00:00.000Z","hasVoted":false}],"meta":{"pagination":{"cursor":null,"hasMore":false}}}"# - return (HTTPURLResponse(url: req.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!, body.data(using: .utf8)!) + return (HTTPURLResponse(url: req.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!, Data(body.utf8)) } let page = try await makeAPI().listPosts(boardId: nil, sort: .newest, cursor: nil) XCTAssertEqual(page.data.first?.id, "post_1") @@ -37,9 +37,10 @@ final class HTTPFeedbackAPITests: XCTestCase { StubURLProtocol.handler = { req in XCTAssertEqual(req.value(forHTTPHeaderField: "Authorization"), "Bearer tok") return (HTTPURLResponse(url: req.url!, statusCode: 401, httpVersion: nil, headerFields: nil)!, - #"{"error":{"code":"UNAUTHORIZED","message":"x"}}"#.data(using: .utf8)!) + Data(#"{"error":{"code":"UNAUTHORIZED","message":"x"}}"#.utf8)) + } + do { _ = try await makeAPI(token: "tok").vote(postId: "post_1"); XCTFail("expected throw") } catch { + XCTAssertEqual(error as? APIError, .unauthorized) } - do { _ = try await makeAPI(token: "tok").vote(postId: "post_1"); XCTFail("expected throw") } - catch { XCTAssertEqual(error as? APIError, .unauthorized) } } } diff --git a/Tests/FeedbackKitTests/MockFeedbackAPI.swift b/Tests/FeedbackKitTests/MockFeedbackAPI.swift index d367d0f..b30a12e 100644 --- a/Tests/FeedbackKitTests/MockFeedbackAPI.swift +++ b/Tests/FeedbackKitTests/MockFeedbackAPI.swift @@ -1,7 +1,20 @@ -import Foundation @testable import FeedbackKit +import Foundation // MARK: - MockFeedbackAPI + +struct SubmittedPost { + var boardId: String + var title: String + var content: String +} + +struct AddedComment { + var postId: String + var content: String + var parentId: String? +} + // Non-final so subclasses can override individual methods to inject failures. class MockFeedbackAPI: @unchecked Sendable, FeedbackAPI { @@ -37,9 +50,9 @@ class MockFeedbackAPI: @unchecked Sendable, FeedbackAPI { // MARK: Recorded inputs - var submitted: (boardId: String, title: String, content: String)? + var submitted: SubmittedPost? var votedPostId: String? - var addedComment: (postId: String, content: String, parentId: String?)? + var addedComment: AddedComment? // MARK: FeedbackAPI conformance @@ -73,7 +86,7 @@ class MockFeedbackAPI: @unchecked Sendable, FeedbackAPI { func submitPost(boardId: String, title: String, content: String) async throws -> PostSummary { if shouldUnauthorize { throw APIError.unauthorized } - submitted = (boardId: boardId, title: title, content: content) + submitted = SubmittedPost(boardId: boardId, title: title, content: content) let post = PostSummary( id: "post_new", title: title, @@ -94,7 +107,7 @@ class MockFeedbackAPI: @unchecked Sendable, FeedbackAPI { func addComment(postId: String, content: String, parentId: String?) async throws -> Comment { if shouldUnauthorize { throw APIError.unauthorized } - addedComment = (postId: postId, content: content, parentId: parentId) + addedComment = AddedComment(postId: postId, content: content, parentId: parentId) return Comment( id: "comment_new", content: content, diff --git a/Tests/FeedbackKitTests/ModelsTests.swift b/Tests/FeedbackKitTests/ModelsTests.swift index 0b564db..eb30f16 100644 --- a/Tests/FeedbackKitTests/ModelsTests.swift +++ b/Tests/FeedbackKitTests/ModelsTests.swift @@ -1,12 +1,13 @@ -import XCTest @testable import FeedbackKit +import XCTest final class ModelsTests: XCTestCase { func testDecodesPostSummaryAndPageEnvelope() throws { - let json = """ + let raw = """ {"data":[{"id":"post_1","title":"A","voteCount":5,"statusId":null,"boardId":"b1","createdAt":"2026-01-01T00:00:00.000Z","hasVoted":true}], "meta":{"pagination":{"cursor":null,"hasMore":false}}} - """.data(using: .utf8)! + """ + let json = Data(raw.utf8) let page = try JSONDecoder.feedback.decode(Page.self, from: json) XCTAssertEqual(page.data.count, 1) XCTAssertEqual(page.data[0].id, "post_1") @@ -15,7 +16,7 @@ final class ModelsTests: XCTestCase { } func testDecodesBareDataEnvelope() throws { - let json = #"{"data":{"id":"post_1","title":"A","content":"x","voteCount":3,"statusId":null,"boardId":"b1","createdAt":"2026-01-01T00:00:00.000Z","hasVoted":false}}"#.data(using: .utf8)! + let json = Data(#"{"data":{"id":"post_1","title":"A","content":"x","voteCount":3,"statusId":null,"boardId":"b1","createdAt":"2026-01-01T00:00:00.000Z","hasVoted":false}}"#.utf8) let env = try JSONDecoder.feedback.decode(Envelope.self, from: json) XCTAssertEqual(env.data.content, "x") } diff --git a/Tests/FeedbackKitTests/PostDetailViewModelTests.swift b/Tests/FeedbackKitTests/PostDetailViewModelTests.swift index ea1bd12..44c994d 100644 --- a/Tests/FeedbackKitTests/PostDetailViewModelTests.swift +++ b/Tests/FeedbackKitTests/PostDetailViewModelTests.swift @@ -1,5 +1,5 @@ -import XCTest @testable import FeedbackKit +import XCTest @MainActor final class PostDetailViewModelTests: XCTestCase { diff --git a/Tests/FeedbackKitTests/ProtocolConformanceTests.swift b/Tests/FeedbackKitTests/ProtocolConformanceTests.swift index 5901d55..cae31cc 100644 --- a/Tests/FeedbackKitTests/ProtocolConformanceTests.swift +++ b/Tests/FeedbackKitTests/ProtocolConformanceTests.swift @@ -1,5 +1,5 @@ -import XCTest @testable import FeedbackKit +import XCTest final class ProtocolConformanceTests: XCTestCase { diff --git a/Tests/FeedbackKitTests/ReadOnlyViewModelsTests.swift b/Tests/FeedbackKitTests/ReadOnlyViewModelsTests.swift index b746422..14a1856 100644 --- a/Tests/FeedbackKitTests/ReadOnlyViewModelsTests.swift +++ b/Tests/FeedbackKitTests/ReadOnlyViewModelsTests.swift @@ -1,5 +1,5 @@ -import XCTest @testable import FeedbackKit +import XCTest @MainActor final class ReadOnlyViewModelsTests: XCTestCase { diff --git a/Tests/FeedbackKitTests/SmokeTests.swift b/Tests/FeedbackKitTests/SmokeTests.swift index 3126232..be4d933 100644 --- a/Tests/FeedbackKitTests/SmokeTests.swift +++ b/Tests/FeedbackKitTests/SmokeTests.swift @@ -1,5 +1,5 @@ -import XCTest @testable import FeedbackKit +import XCTest final class SmokeTests: XCTestCase { func testModuleLoads() { diff --git a/Tests/FeedbackKitTests/SubmitViewModelTests.swift b/Tests/FeedbackKitTests/SubmitViewModelTests.swift index cc28614..57757ce 100644 --- a/Tests/FeedbackKitTests/SubmitViewModelTests.swift +++ b/Tests/FeedbackKitTests/SubmitViewModelTests.swift @@ -1,5 +1,5 @@ -import XCTest @testable import FeedbackKit +import XCTest @MainActor final class SubmitViewModelTests: XCTestCase { diff --git a/Tests/FeedbackKitTests/TokenStoreTests.swift b/Tests/FeedbackKitTests/TokenStoreTests.swift index 7fdb33a..10b551f 100644 --- a/Tests/FeedbackKitTests/TokenStoreTests.swift +++ b/Tests/FeedbackKitTests/TokenStoreTests.swift @@ -1,5 +1,5 @@ -import XCTest @testable import FeedbackKit +import XCTest final class TokenStoreTests: XCTestCase { func testInMemoryStoreRoundTrips() { diff --git a/project.yml b/project.yml index 59a58f8..66fbd55 100644 --- a/project.yml +++ b/project.yml @@ -94,18 +94,6 @@ targets: INFOPLIST_FILE: App/FeedbackPortalApp/Info.plist FEEDBACK_INSTANCE_URL: "http://localhost:3000" - FeedbackAppTests: - type: bundle.unit-test - platform: iOS - deploymentTarget: "15.0" - sources: - - FeedbackApp/Tests - dependencies: - - target: FeedbackApp - settings: - base: - PRODUCT_BUNDLE_IDENTIFIER: dev.opencoven.feedback.tests - schemes: FeedbackPortalApp: build: From d1b2eef67d74e617d03d464dfc97ca97cb2b4b15 Mon Sep 17 00:00:00 2001 From: apeltekci Date: Fri, 29 May 2026 08:06:37 -0700 Subject: [PATCH 17/24] fix(ci): concurrency-safe SDK event tests (Swift 5.10) + FeedbackPortalApp iOS 17 target Co-Authored-By: Claude Sonnet 4.6 --- .../OpenCovenFeedbackEventTests.swift | 65 ++++++++++++------- project.yml | 2 +- 2 files changed, 41 insertions(+), 26 deletions(-) diff --git a/Tests/OpenCovenFeedbackTests/OpenCovenFeedbackEventTests.swift b/Tests/OpenCovenFeedbackTests/OpenCovenFeedbackEventTests.swift index 230d435..f5cf265 100644 --- a/Tests/OpenCovenFeedbackTests/OpenCovenFeedbackEventTests.swift +++ b/Tests/OpenCovenFeedbackTests/OpenCovenFeedbackEventTests.swift @@ -1,6 +1,19 @@ @testable import OpenCovenFeedback import XCTest +private final class Counter: @unchecked Sendable { + private let lock = NSLock() + private var value = 0 + func increment() { lock.lock(); value += 1; lock.unlock() } + var count: Int { lock.lock(); defer { lock.unlock() }; return value } +} +private final class EventLog: @unchecked Sendable { + private let lock = NSLock() + private var events: [OpenCovenFeedbackEvent] = [] + func append(_ e: OpenCovenFeedbackEvent) { lock.lock(); events.append(e); lock.unlock() } + var all: [OpenCovenFeedbackEvent] { lock.lock(); defer { lock.unlock() }; return events } +} + final class OpenCovenFeedbackEventTests: XCTestCase { func testAddAndFire() { let emitter = EventEmitter() @@ -15,41 +28,42 @@ final class OpenCovenFeedbackEventTests: XCTestCase { func testRemove() { let emitter = EventEmitter() - var count = 0 - let token = emitter.on(.submit) { _ in count += 1 } - emitter.emit(.submit, data: [:]); XCTAssertEqual(count, 1) + let counter = Counter() + let token = emitter.on(.submit) { _ in counter.increment() } + emitter.emit(.submit, data: [:]); XCTAssertEqual(counter.count, 1) emitter.off(token) - emitter.emit(.submit, data: [:]); XCTAssertEqual(count, 1) + emitter.emit(.submit, data: [:]); XCTAssertEqual(counter.count, 1) } func testRemoveAll() { let emitter = EventEmitter() - var count = 0 - emitter.on(.vote) { _ in count += 1 } - emitter.on(.submit) { _ in count += 1 } + let counter = Counter() + emitter.on(.vote) { _ in counter.increment() } + emitter.on(.submit) { _ in counter.increment() } emitter.emit(.vote, data: [:]); emitter.emit(.submit, data: [:]) - XCTAssertEqual(count, 2) + XCTAssertEqual(counter.count, 2) emitter.removeAll() - emitter.emit(.vote, data: [:]); XCTAssertEqual(count, 2) + emitter.emit(.vote, data: [:]); XCTAssertEqual(counter.count, 2) } func testMultipleListenersSameEvent() { let emitter = EventEmitter() - var count1 = 0, count2 = 0 - emitter.on(.vote) { _ in count1 += 1 } - emitter.on(.vote) { _ in count2 += 1 } + let counter1 = Counter() + let counter2 = Counter() + emitter.on(.vote) { _ in counter1.increment() } + emitter.on(.vote) { _ in counter2.increment() } emitter.emit(.vote, data: [:]) - XCTAssertEqual(count1, 1) - XCTAssertEqual(count2, 1) + XCTAssertEqual(counter1.count, 1) + XCTAssertEqual(counter2.count, 1) } func testOffWithNonExistentToken() { let emitter = EventEmitter() - var count = 0 - emitter.on(.vote) { _ in count += 1 } + let counter = Counter() + emitter.on(.vote) { _ in counter.increment() } emitter.off(EventToken()) // non-existent token emitter.emit(.vote, data: [:]) - XCTAssertEqual(count, 1) // listener still fires + XCTAssertEqual(counter.count, 1) // listener still fires } func testEmitWithNoListeners() { @@ -60,23 +74,24 @@ final class OpenCovenFeedbackEventTests: XCTestCase { func testRemoveOnlyTargetListener() { let emitter = EventEmitter() - var count1 = 0, count2 = 0 - emitter.on(.vote) { _ in count1 += 1 } - let token2 = emitter.on(.vote) { _ in count2 += 1 } + let counter1 = Counter() + let counter2 = Counter() + emitter.on(.vote) { _ in counter1.increment() } + let token2 = emitter.on(.vote) { _ in counter2.increment() } emitter.off(token2) emitter.emit(.vote, data: [:]) - XCTAssertEqual(count1, 1) - XCTAssertEqual(count2, 0) + XCTAssertEqual(counter1.count, 1) + XCTAssertEqual(counter2.count, 0) } func testAllEventTypes() { let emitter = EventEmitter() - var received: [OpenCovenFeedbackEvent] = [] + let log = EventLog() for event in [OpenCovenFeedbackEvent.ready, .vote, .submit, .close, .navigate] { - emitter.on(event) { _ in received.append(event) } + emitter.on(event) { _ in log.append(event) } emitter.emit(event, data: [:]) } - XCTAssertEqual(received, [.ready, .vote, .submit, .close, .navigate]) + XCTAssertEqual(log.all, [.ready, .vote, .submit, .close, .navigate]) } func testEventRawValues() { diff --git a/project.yml b/project.yml index 66fbd55..6295307 100644 --- a/project.yml +++ b/project.yml @@ -59,7 +59,7 @@ targets: FeedbackPortalApp: type: application platform: iOS - deploymentTarget: "15.0" + deploymentTarget: "17.0" sources: - path: App/FeedbackPortalApp excludes: From 73b42a6613c6660d3b0f94d0ce0a56c4766987af Mon Sep 17 00:00:00 2001 From: apeltekci Date: Fri, 29 May 2026 08:16:04 -0700 Subject: [PATCH 18/24] fix(ci): rename 1-char identifier to satisfy swiftlint --strict --- Tests/OpenCovenFeedbackTests/OpenCovenFeedbackEventTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/OpenCovenFeedbackTests/OpenCovenFeedbackEventTests.swift b/Tests/OpenCovenFeedbackTests/OpenCovenFeedbackEventTests.swift index f5cf265..fd20788 100644 --- a/Tests/OpenCovenFeedbackTests/OpenCovenFeedbackEventTests.swift +++ b/Tests/OpenCovenFeedbackTests/OpenCovenFeedbackEventTests.swift @@ -10,7 +10,7 @@ private final class Counter: @unchecked Sendable { private final class EventLog: @unchecked Sendable { private let lock = NSLock() private var events: [OpenCovenFeedbackEvent] = [] - func append(_ e: OpenCovenFeedbackEvent) { lock.lock(); events.append(e); lock.unlock() } + func append(_ event: OpenCovenFeedbackEvent) { lock.lock(); events.append(event); lock.unlock() } var all: [OpenCovenFeedbackEvent] { lock.lock(); defer { lock.unlock() }; return events } } From 6b5901a63ca917a68ec97da327ad266ae03fb1c6 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Fri, 29 May 2026 12:57:58 -0500 Subject: [PATCH 19/24] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- Sources/FeedbackKit/Cache/ContentCache.swift | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Sources/FeedbackKit/Cache/ContentCache.swift b/Sources/FeedbackKit/Cache/ContentCache.swift index ff2bf9d..8d1bc20 100644 --- a/Sources/FeedbackKit/Cache/ContentCache.swift +++ b/Sources/FeedbackKit/Cache/ContentCache.swift @@ -3,11 +3,11 @@ import Foundation public struct ContentCache: Sendable { private let directory: URL - public init(directory: URL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] - .appendingPathComponent("FeedbackKit")) { - self.directory = directory - try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) - } +public init(directory: URL = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0] + .appendingPathComponent("FeedbackKit")) { + self.directory = directory + try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil) +} public func save(_ value: T, as key: String) throws { let data = try Self.encoder.encode(value) From 42462c9487294391c4fbf2e247d436f6744ae408 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Fri, 29 May 2026 12:58:22 -0500 Subject: [PATCH 20/24] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- Sources/FeedbackKit/API/HTTPFeedbackAPI.swift | 36 ++++++++++--------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/Sources/FeedbackKit/API/HTTPFeedbackAPI.swift b/Sources/FeedbackKit/API/HTTPFeedbackAPI.swift index 0bd1f57..9342435 100644 --- a/Sources/FeedbackKit/API/HTTPFeedbackAPI.swift +++ b/Sources/FeedbackKit/API/HTTPFeedbackAPI.swift @@ -94,25 +94,27 @@ public final class HTTPFeedbackAPI: FeedbackAPI, @unchecked Sendable { // MARK: - Private helpers - private func request(path: String, method: String, query: [(String, String)]) -> URLRequest { - var components = URLComponents(url: baseURL.appendingPathComponent(path), resolvingAgainstBaseURL: false)! - // appendingPathComponent may double-encode slashes; rebuild the path directly - components = URLComponents() - components.scheme = baseURL.scheme - components.host = baseURL.host - components.port = baseURL.port - components.path = path - if !query.isEmpty { - components.queryItems = query.map { URLQueryItem(name: $0.0, value: $0.1) } - } - var req = URLRequest(url: components.url!) - req.httpMethod = method - if let token = tokenProvider() { - req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") - } - return req +private func request(path: String, method: String, query: [(String, String)]) -> URLRequest { + var components = URLComponents() + components.scheme = baseURL.scheme + components.host = baseURL.host + components.port = baseURL.port + + let basePath = baseURL.path == "/" ? "" : baseURL.path + components.path = basePath + path + + if !query.isEmpty { + components.queryItems = query.map { URLQueryItem(name: $0.0, value: $0.1) } } + var req = URLRequest(url: components.url!) + req.httpMethod = method + if let token = tokenProvider() { + req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + } + return req +} + private func send(_ request: URLRequest) async throws -> R { let (data, response): (Data, URLResponse) do { From 139c3ded6af07a19cd4ebdeea5870cca173ead81 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Fri, 29 May 2026 12:58:38 -0500 Subject: [PATCH 21/24] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../Features/Submit/SubmitViewModel.swift | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/Sources/FeedbackKit/Features/Submit/SubmitViewModel.swift b/Sources/FeedbackKit/Features/Submit/SubmitViewModel.swift index 9957170..4bfc848 100644 --- a/Sources/FeedbackKit/Features/Submit/SubmitViewModel.swift +++ b/Sources/FeedbackKit/Features/Submit/SubmitViewModel.swift @@ -16,11 +16,14 @@ public final class SubmitViewModel: ObservableObject { self.api = api; self.isSignedIn = isSignedIn } - @discardableResult - public func submit() async -> Bool { - errorMessage = nil - guard isSignedIn() else { needsSignIn = true; return false } - guard !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { +@discardableResult +public func submit() async -> Bool { + needsSignIn = false + errorMessage = nil + guard isSignedIn() else { needsSignIn = true; return false } + guard !title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + errorMessage = "Title is required."; return false + } errorMessage = "Title is required."; return false } guard !boardId.isEmpty else { errorMessage = "Pick a board."; return false } From def3914bbffee75ea4641d7133f9fab354fc61a3 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Fri, 29 May 2026 12:58:57 -0500 Subject: [PATCH 22/24] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- Sources/FeedbackKit/Auth/AuthStore.swift | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/Sources/FeedbackKit/Auth/AuthStore.swift b/Sources/FeedbackKit/Auth/AuthStore.swift index d8d84b0..be055df 100644 --- a/Sources/FeedbackKit/Auth/AuthStore.swift +++ b/Sources/FeedbackKit/Auth/AuthStore.swift @@ -21,9 +21,15 @@ public final class AuthStore: ObservableObject { public var token: String? { tokenStore.token } - public func requestCode(email: String) async throws { +public func requestCode(email: String) async throws { + errorMessage = nil + do { try await service.sendOTP(email: email) + } catch { + errorMessage = "Couldn't send a code. Please try again." + throw error } +} public func verify(email: String, code: String) async { errorMessage = nil From 5f71b89a6a8aae366829d20c35754db1a3481989 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Fri, 29 May 2026 12:59:11 -0500 Subject: [PATCH 23/24] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../FeedbackKit/Features/Detail/PostDetailViewModel.swift | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/Sources/FeedbackKit/Features/Detail/PostDetailViewModel.swift b/Sources/FeedbackKit/Features/Detail/PostDetailViewModel.swift index 4a71898..bd980fe 100644 --- a/Sources/FeedbackKit/Features/Detail/PostDetailViewModel.swift +++ b/Sources/FeedbackKit/Features/Detail/PostDetailViewModel.swift @@ -54,9 +54,10 @@ public final class PostDetailViewModel: ObservableObject { } } - public func addComment(_ text: String) async { - guard isSignedIn() else { needsSignIn = true; return } - do { +public func addComment(_ text: String) async { + needsSignIn = false + guard isSignedIn() else { needsSignIn = true; return } + do { let comment = try await api.addComment(postId: postId, content: text, parentId: nil) comments.insert(comment, at: 0) } catch APIError.unauthorized { From f4669a737dc073024d5a23d53f2f88b16c277a64 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Fri, 29 May 2026 12:59:24 -0500 Subject: [PATCH 24/24] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .../FeedbackKit/Features/Detail/PostDetailViewModel.swift | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/Sources/FeedbackKit/Features/Detail/PostDetailViewModel.swift b/Sources/FeedbackKit/Features/Detail/PostDetailViewModel.swift index bd980fe..7a9c657 100644 --- a/Sources/FeedbackKit/Features/Detail/PostDetailViewModel.swift +++ b/Sources/FeedbackKit/Features/Detail/PostDetailViewModel.swift @@ -31,9 +31,10 @@ public final class PostDetailViewModel: ObservableObject { isLoading = false } - public func toggleVote() async { - guard isSignedIn() else { needsSignIn = true; return } - do { +public func toggleVote() async { + needsSignIn = false + guard isSignedIn() else { needsSignIn = true; return } + do { let result = try await api.vote(postId: postId) if let current = post { post = PostDetail(