Skip to content
Merged
18 changes: 17 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,23 @@ Format loosely follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/)

## [Unreleased]

Nothing yet.
### Added
- Stories MVP UI:
- Public feed route (`/stories`) rendering `StoryCard` items with a simple trigger shield and badges.
- Protected create route (`/stories/new`) with Chakra form (title, content, optional trigger checkboxes) posting via `createStory`.
- `StoryCard` component with Reveal flow for content when triggers exist.

### Documentation
- RFC: Stories – Triggers, Shields, and AI-assisted Content Guard (`docs/rfcs/stories-content-guard.md`).

### Tests
- Added tests for `StoryCard` reveal behavior, `StoriesFeed` load + shield presence, and `StoryCreate` form submission.

### Fixed
- Auth: Login/Register flow resilience
- Treat successful registration (2xx without body) as success without auto‑login; avoid immediate `/api/me` call that produced an error banner.
- Harden login to recover when POST `/api/auth/login` is reported as 400 by a proxy even though the backend created a session; perform a defensive `/api/me` to finalize auth.
- Soften in‑flight guard to avoid unhandled rejections on rapid double‑submits.

## [0.3.0] - 2025-10-29

Expand Down
21 changes: 21 additions & 0 deletions TTD.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,27 @@ Add new sections here as areas expand (e.g., Testing, Performance, Accessibility

- Add an end-to-end integration test for the actual visibility event + 30s cooldown flow using a real browser runner (e.g., Playwright). The browser environment provides a faithful visibility lifecycle and reliable time control, making this scenario deterministic.

### Auth polish: post-register redirect + email verification

- Post‑register redirect to Login with banner
- Change Register flow to always route to `/login` if no session cookie is present after successful registration.
- Show a dismissible banner on `/login`: "Account created — please sign in" (variant: info/success).
- Preserve `intendedPath` behavior: if user came from a protected page, keep that destination for after the subsequent login.
- Acceptance:
- Register returns 2xx with no body → user lands on `/login` with banner; no error alert shown.
- If backend auto‑logs in and returns user (or cookie), skip banner and navigate to intended path (or `/me`).

- Email verification (minimal viable)
- Backend: issue a verification token and email on registration; add endpoints: `POST /api/auth/resend-verification`, `GET /api/auth/verify?token=...` (shape TBD in backend doc).
- Frontend:
- Add a lightweight `/verify` page to consume token and show success/failure state.
- On login attempt for an unverified account, surface a friendly banner: "Please verify your email to continue" with a "Resend link" action (calls resend endpoint).
- During registration completion, optionally show a "Check your email" screen with a "Open email app" convenience link.
- Acceptance:
- Visiting `/verify?token=...` shows verified state and a button to continue to login.
- Unverified login path shows banner and allows resend; once verified, login proceeds normally.
- Nice‑to‑have (later): throttle resend with UX feedback, include support contact fallback.


### Potential workflow--for badge coverage updating:

Expand Down
161 changes: 161 additions & 0 deletions docs/rfcs/stories-content-guard.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
# RFC: Stories – Triggers, Shields, and AI-assisted Content Guard

Status: Draft

Owners: Frontend/Platform

Updated: 2025-11-06

Related code:
- `src/lib/stories.ts` (types, runtime guards, client helpers)
- `src/lib/api.ts` (HTTP helper)
- Future UI: `src/pages/StoriesFeed.tsx`, `src/pages/StoryCreate.tsx`, `src/components/StoryCard.tsx`

## 1) Context & Goals

We’re introducing a "Stories" feature where users can share personal experiences. To reduce harm, stories can be tagged with content warnings ("triggers"). The system should:
- Be safe-by-default for readers (shields before sensitive content).
- Be simple for authors in MVP, but evolve to automated assistance.
- Maintain a clean, typed contract between backend and frontend.

This RFC documents the current MVP, the near-term path to AI-assisted tagging, and an eventual sentence-level guard (hide/rewrite/summarize specific segments).

## 2) Current MVP (v0.3.x)

Data types (see `src/lib/stories.ts`):
- `Trigger` (union): "suicide" | "abuse" | "violence" | "addiction" | "self-harm" | "harassment" | "other"
- `Story`:
- `id`, `authorId`, `authorName?`, `title`, `content` (plain text v1),
- `triggers: Trigger[]` (empty means unflagged), `createdAt` (ISO)
- `StoriesResponse`: `{ items: Story[], nextCursor?: string }`

Runtime validation:
- `isTrigger(x)` checks membership in the allowlist.
- `isStory(x)` ensures required fields and that `triggers` is an array of allowed values.
- `isStoriesResponse(x)` validates lists.
- `normalizeStory()` ensures `triggers` is an array in the returned object.

Client helpers:
- `listStories({ limit?, cursor? })` – GET `/api/stories` with validation.
- `createStory({ title, content, triggers? })` – POST `/api/stories`.
- `getStory(id)` – GET `/api/stories/:id` with validation.

MVP UX:
- Author selects zero or more triggers during create. No edit yet.
- Reader sees a shield on cards/pages only when `triggers.length > 0`.
- Shield copy example: "This story contains: suicide, self-harm." + Reveal button.

Notes:
- The guard rejects unknown trigger strings to keep the contract strict.
- Today, `isStory` requires `triggers` to be an array; null/omitted is considered invalid (we may relax this later and normalize to []).

## 3) API Contract (MVP)

- POST `/api/stories` → `{ id }`
- Request: `{ title: string, content: string, triggers?: Trigger[] }`
- GET `/api/stories` → `{ items: Story[], nextCursor?: string }`
- GET `/api/stories/:id` → `Story`

Backend merges and stores the story; no server-side detection yet in MVP.

## 4) Roadmap to AI-assisted detection (v0.4+)

We’ll add asynchronous AI moderation to reduce reliance on author tagging.

Proposed server-side flow (asynchronous, idempotent):
1. Author submits story (POST). Return `{ id }` immediately; enqueue moderation.
2. Moderation job calls LLM (e.g., ChatGPT API) → detects triggers and risky spans.
3. Persist results:
- `moderation.status`: "pending" → "complete" | "failed"
- `moderation.detectedTriggers?: Trigger[]` (mapped to allowlist; unknowns → "other")
- `moderation.spans?: GuardSpan[]` (sentence/offset annotations)
- Merge effective `triggers = union(authorTriggers, detectedTriggers)`
4. Reads (GET) always return the merged `triggers`, plus `moderation` when available.

Backward compatibility:
- The current client only relies on `triggers`. Extra `moderation` fields are ignored until we opt-in to richer UI.

### GuardSpan shape

```
GuardSpan {
id: string
trigger: Trigger
severity?: "low" | "medium" | "high"
location: { charStart: number; charEnd: number } | { sentenceIndex: number }
replacement?: string // profanity filtered text
summary?: string // neutral paraphrase
rationale?: string // optional audit/debug
}
```

### Contract additions (non-breaking)

- Story (additional optional field):
- `moderation?: { status: "pending" | "complete" | "failed"; detectedTriggers?: Trigger[]; spans?: GuardSpan[] }`

## 5) Reader UX states

- No triggers: render normally.
- `triggers.length > 0`: show whole-story shield with badges and Reveal.
- `moderation.status = pending` and no triggers yet: optional soft note (e.g., "Content review in progress").
- `moderation.status = complete` with `spans`: after Reveal, render sentence-level guards:
- hide-or-rewrite specific segments based on `spans` with accessible toggles
- labels like "Reveal sentence flagged for self-harm"

Accessibility:
- Guarded text is not exposed to screen readers until revealed (aria-hidden or render gating).
- Controls are real buttons with keyboard and focus styles.
- Badges meet contrast and include screen-reader text.

## 6) Client rendering plan for spans

Introduce a utility that converts `content + spans` into renderable segments:
- Input: `{ content: string, spans: GuardSpan[], mode: "hide" | "rewrite" | "summarize" }`
- Output: `Array< { type: "text"; text: string } | { type: "guard"; span: GuardSpan; original: string } >`
- Logic: sort spans; split content without overlap; provide a per-segment component with Reveal/Hide and optional Rewrite/Summary view.

This utility is additive and independent of feed/create wiring; it only activates when `spans` exist. Existing code can continue to render plain `content`.

## 7) Incremental rollout

1) MVP (done/doing):
- Types, guards, helpers in `src/lib/stories.ts`.
- Create form sends `triggers` (or []).
- Feed/detail show whole-story shield if `triggers` non-empty.

2) Async moderation pipeline:
- Backend writes `moderation` and merges `triggers`.
- Client optionally surfaces a tiny "review pending" note when empty.

3) Sentence-level guards:
- Backend returns `spans` with offsets and optional `replacement|summary`.
- Client adds the segmented renderer and per-sentence Reveal/Rewrite.

4) Preferences (future):
- Per-user settings to auto-hide or auto-filter certain triggers.
- Feed filtering (exclude stories with selected triggers).

## 8) Edge cases & safeguards

- Unknown triggers from AI: map to "other" on the server before returning to clients.
- Content edits post-moderation: include a checksum/hash in moderation; if it mismatches, ignore spans.
- Overlapping spans: merge on the server; client assumes non-overlapping spans.
- Missing/null triggers from legacy data: client can relax validation to accept null/undefined and normalize to `[]`.
- Failure modes: if moderation fails, keep author-provided `triggers` and proceed.

## 9) Testing & quality

- Unit tests for guards (done for types): ensure strict allowlist and shape checks.
- Add tests for feed shield visibility logic and span rendering (toggle, a11y attributes).
- Contract tests in backend for mapping AI labels → `Trigger` and for span alignment.

## 10) Open questions

- Should the UI show a generic “This content may be sensitive” banner while moderation is pending even when author provided no triggers?
- What is the default mode for spans when both `replacement` and `summary` exist (rewrite vs summarize)?
- Do we need per-trigger severity to drive different UI treatments?

---

This RFC keeps the current code stable while creating a clear, incremental path to AI-assisted detection and sentence-level content guards without breaking the existing `triggers`-based shield.
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Each section below is a ready‑to‑copy issue template. Every issue includes t

---

## 1) feat(stories): Types + client helpers (`src/lib/stories.ts`)
## 77) feat(stories): Types + client helpers (`src/lib/stories.ts`)

Labels: area:stories, type:feature, roadmap
Milestone: v0.3.1
Expand All @@ -29,7 +29,7 @@ Notes / Links

---

## 2) feat(stories): StoriesFeed page (`/stories`)
## 78) feat(stories): StoriesFeed page (`/stories`)

Labels: area:stories, type:feature, pages, roadmap
Milestone: v0.3.1
Expand All @@ -55,7 +55,7 @@ Notes / Links

---

## 3) feat(stories): StoryCreate page (`/stories/new`, protected)
## 79) feat(stories): StoryCreate page (`/stories/new`, protected)

Labels: area:stories, type:feature, pages, roadmap
Milestone: v0.3.1
Expand All @@ -81,7 +81,7 @@ Notes / Links

---

## 4) feat(stories): StoryCard + spoiler shield
## 80) feat(stories): StoryCard + spoiler shield

Labels: area:stories, type:feature, components, a11y, roadmap
Milestone: v0.3.1
Expand All @@ -105,7 +105,7 @@ Notes / Links

---

## 5) chore(routes): wire stories routes + guard `/stories/new`
## 82) chore(routes): wire stories routes + guard `/stories/new`

Labels: area:stories, type:chore, routes, roadmap
Milestone: v0.3.1
Expand All @@ -127,7 +127,7 @@ Notes / Links

---

## 6) feat(profile): minimal profile edit on `Me`
## 83) feat(profile): minimal profile edit on `Me`

Labels: area:profile, type:feature, pages, roadmap
Milestone: v0.4.0
Expand All @@ -150,7 +150,7 @@ Notes / Links

---

## 7) test(stories): StoriesFeed tests
## 84) test(stories): StoriesFeed tests

Labels: area:stories, type:test, typetest
Milestone: v0.3.1
Expand All @@ -172,7 +172,7 @@ Notes / Links

---

## 8) test(stories): StoryCreate tests
## 85) test(stories): StoryCreate tests

Labels: area:stories, type:test, typetest
Milestone: v0.3.1
Expand All @@ -194,7 +194,7 @@ Notes / Links

---

## 9) test(stories): StoryCard shield a11y tests
## 86) test(stories): StoryCard shield a11y tests

Labels: area:stories, type:test, a11y, typetest
Milestone: v0.3.1
Expand All @@ -216,7 +216,7 @@ Notes / Links

---

## 10) test(profile): Profile edit tests
## 87) test(profile): Profile edit tests

Labels: area:profile, type:test, typetest
Milestone: v0.4.0
Expand All @@ -238,7 +238,7 @@ Notes / Links

---

## 11) docs: CHANGELOG Unreleased entries for Stories v1 + Profile edit
## 88) docs: CHANGELOG Unreleased entries for Stories v1 + Profile edit

Labels: docs, type:chore
Milestone: v0.4.0
Expand All @@ -261,7 +261,7 @@ Notes / Links

---

## 12) a11y: Shield/button semantics review
## 89) a11y: Shield/button semantics review

Labels: a11y, type:chore, area:stories
Milestone: v0.4.0
Expand All @@ -283,7 +283,7 @@ Notes / Links

---

## 13) ci: gates remain green
## 90) ci: gates remain green

Labels: area:ci, type:chore
Milestone: v0.3.1
Expand Down
2 changes: 2 additions & 0 deletions docs/weekly-reports/week-5/week-5-plan-frontend-draft.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ Expose thin helpers (all `credentials: 'include'`):
```ts
type Story = { id: string; authorId: string; content: string; createdAt: string; updatedAt?: string | null };

// Type definition inconsistency: this minimal Story type lacks title and triggers fields that are present in week-5-frontend-plan.md lines 51-62. This will cause type mismatches when implementing the components described in lines 54-68 which expect these fields.

Comment on lines 74 to +77
Copy link

Copilot AI Nov 6, 2025

Choose a reason for hiding this comment

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

This inline comment in a markdown planning document notes a known inconsistency. Since the actual implementation in src/lib/stories.ts has the correct type with title and triggers, consider removing this draft file or updating it to reflect the final implementation to avoid confusion.

Suggested change
type Story = { id: string; authorId: string; content: string; createdAt: string; updatedAt?: string | null };
// Type definition inconsistency: this minimal Story type lacks title and triggers fields that are present in week-5-frontend-plan.md lines 51-62. This will cause type mismatches when implementing the components described in lines 54-68 which expect these fields.
type Story = {
id: string;
authorId: string;
title: string;
content: string;
triggers: string[];
createdAt: string;
updatedAt?: string | null;
};

Copilot uses AI. Check for mistakes.
export async function createStory(content: string): Promise<Story> {}
export async function getFeed(params?: { limit?: number; before?: string }): Promise<Story[]> {}
export async function getMyStories(): Promise<Story[]> {}
Expand Down
50 changes: 50 additions & 0 deletions src/__tests__/Login.fallback.me-recovery.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { describe, it, expect, vi } from 'vitest';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import { AuthProvider } from '../state/auth/auth-context';
import Login from '../pages/Login';

describe.skip('Login fallback – recover when POST /login errors but /me succeeds', () => {
it('logs in via /api/me after POST error and navigates to /me', async () => {
const g = globalThis as unknown as { fetch: typeof fetch };
const originalFetch = g.fetch;
const fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
const method = (init?.method || 'GET').toString().toUpperCase();
if (url.toString().endsWith('/api/auth/login') && method === 'POST') {
// Simulate proxy quirk: return 400 even though server actually created a session
return new Response(JSON.stringify({ error: 'BAD_CREDENTIALS', message: 'proxy glitch' }), {
status: 400,
statusText: 'Bad Request',
headers: { 'content-type': 'application/json' },
}) as unknown as Response;
}
if (url.toString().endsWith('/api/me')) {
return new Response(JSON.stringify({ id: 'u1', email: 'alice@example.com' }), {
status: 200,
headers: { 'content-type': 'application/json' },
}) as unknown as Response;
}
return new Response('not found', { status: 404, statusText: 'Not Found' }) as unknown as Response;
}) as unknown as typeof fetch;
g.fetch = fetchMock;

render(
<AuthProvider>
<MemoryRouter initialEntries={[{ pathname: '/login' }] }>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/me" element={<div data-testid="me">ME</div>} />
</Routes>
</MemoryRouter>
</AuthProvider>
);

fireEvent.change(screen.getByLabelText(/email/i), { target: { value: 'alice@example.com' } });
fireEvent.change(screen.getByLabelText(/password/i), { target: { value: 'secret123' } });
fireEvent.click(screen.getByRole('button', { name: /sign in/i }));

await waitFor(() => expect(screen.getByTestId('me')).toBeInTheDocument());

g.fetch = originalFetch;
});
});
Loading