# Source / Research Attachments

Specification for how source material is attached to a vote item — both when proposed by a citizen and when displayed on the vote card and vote detail.

---

## Overview

Every vote item can carry one or more source attachments. A single source renders as a card. Multiple sources render as a horizontally swipeable carousel.

Sources are attached at proposal time (via Propose a Vote) and stored on the vote item. They surface in two places:
- **VOTD card** — revealed when the user expands "Read more"
- **Vote item screen** — shown in the source section below the body copy

---

## Attachment Tiers

### Tier 1 — Simple Link
The baseline. A raw URL with a "View vote source" tap target.

```ts
{
  type: 'link',
  url: 'https://...',
}
```

Display: plain text link with external arrow icon. Taps open in system browser.

---

### Tier 2 — Link Preview
A URL where OG metadata has been fetched — image, title, description, domain. Visually rich, still tappable to open in browser.

```ts
{
  type: 'link',
  url: 'https://...',
  meta: {
    title: 'Sacramento City Council Agenda — Item 14B',
    description: 'Proposed amendment to zoning ordinance...',
    image: 'https://...og-image.jpg',
    domain: 'sacramento.gov',
  }
}
```

Display: card with OG image (if available), title, description snippet, domain label. Same card style as Propose a Vote link preview.

---

### Tier 3 — Uploaded File
A PDF, Word doc, or image uploaded by the proposer. Has an associated caption.

```ts
{
  type: 'file',
  url: 'https://...file.pdf',       // storage URL
  fileName: 'Agenda_Item_14B.pdf',
  fileType: 'pdf',                   // 'pdf' | 'doc' | 'image'
  caption: 'City Council Agenda, March 2026',
}
```

Display: file preview card — icon (PDF/doc/image), file name, caption. Taps to open the file.

---

### Tier 4 — Multiple Sources (Carousel)
When a vote item has more than one source attachment, they render as a horizontal carousel of cards — swipeable, with a dot indicator.

Any combination of Tier 2 and Tier 3 cards can appear in the carousel.

```ts
sources: [
  { type: 'link', url: '...', meta: { ... } },
  { type: 'file', url: '...', fileName: '...', caption: '...' },
  { type: 'link', url: '...', meta: { ... } },
]
```

Display: horizontal slider (FlatList), each card same fixed width, manual swipe only — no auto-scroll, dot pagination below.

---

## Data Model

```ts
export type VoteSource = {
  type:       'link' | 'file';
  url:        string;
  caption?:   string;
  // Link preview (Tier 2)
  meta?: {
    title?:       string;
    description?: string;
    image?:       string;
    domain?:      string;
  };
  // File attachment (Tier 3)
  fileName?:  string;
  fileType?:  'pdf' | 'doc' | 'image';
};

// Added to MockVoteItem:
sources?: VoteSource[];
```

Single source (`sources.length === 1`) → single card.
Multiple sources (`sources.length > 1`) → carousel.
No sources → fallback to Tier 1 plain link (existing `agendaUrl` or equivalent field).

---

## Propose a Vote Integration

The Propose a Vote modal already supports:
- Pasting a URL → fetches OG meta → renders link preview card (Tier 2)
- Uploading a file → renders file preview card (Tier 3)

When a proposal is submitted, the attached source(s) are stored as `VoteSource[]` on the vote item. The same data structure flows through to display.

Additional sources can be added (up to a reasonable limit, e.g. 3) which triggers carousel display.

---

## Display Contexts

| Context | Single source | Multiple sources |
|---|---|---|
| VOTD card (expanded) | Single source card | Carousel |
| Vote item screen | Single source card | Carousel |
| Propose a Vote | Preview card(s) during authoring | Stacked list during authoring |

---

## Community Research — Add Research (AR1–AR8)

Beyond the proposer's own source(s), any verified voter can add research to an open vote item. This turns the source section into a living, community-built evidence base.

Comp reference: `docs/all-comps.html` → Add Research section (AR1–AR8).

### Entry points

1. **Vote item screen** — inline "Add Research" CTA row (AR1) sits below existing sources in the Sources / Research section.
2. **AddResearch card in feed** — feed engine surfaces AddResearch cards at slots 8 and 22 (defined in `DESIGN_DECISIONS.md`) for votes 5–12 days old with the fewest sources. Tapping opens the vote item where the CTA is visible.

### Access control — verified voters only

Adding research is gated to `verified` voters (see `schema.md`, "Feature access by verification level"). The gate uses the same `SkipOverlay` component as Propose a Vote (comps F5a, F5b):

| Verification level | Can add research? | Prompt |
|---|---|---|
| `unverified` (guest) | No | "Adding research requires voter verification" → **Sign Up** / Skip for now |
| `phone_verified` | No | Same message → **Finish Sign Up** / Skip for now |
| `verifying` | No | Same as phone-verified — match not yet confirmed |
| `verified` | Yes | Bottom sheet opens (AR2) |

Rationale: research submission is public civic attribution — the contributor's name is attached to a source visible to all users. Voter roll match (`verified`) confirms name + address + voter registration, the identity confidence needed for public contributions.

### Submission flow (AR2–AR7)

Once a verified voter taps the CTA, the bottom sheet opens with two paths:

**Paste a link (AR2 → AR3 → AR4):**
1. User taps "Paste a link" in the picker (AR2).
2. User pastes or types a URL.
3. App fetches OG metadata automatically. "Fetching preview…" loading state shown (AR3).
4. Preview card populates with domain, title, description, OG image (AR4).
5. Root domain detection: homepage-only URLs (e.g. `sacbee.com` with no path) show a nudge and block submission.
6. Domain blacklist check runs server-side. Blocked domains show inline error: "This source domain is not permitted."
7. "Revise" clears the URL to paste a different one.
8. "Add This" submits.

**Upload a file (AR2 → AR5 → AR6 → AR7):**
1. User taps "Upload a file" in the picker (AR2).
2. Dashed upload card: "Add an image or document — Photo, screenshot, or PDF" (AR5).
3. Tapping triggers iOS action sheet: Take Photo / Photo Library / Browse Files / Cancel (AR6).
4. File preview with "Revise" action to pick a different file (AR7).
5. **Source field is mandatory** — labeled "Source (required)." User enters who published the content.
6. "Add This" button is dimmed until source field is filled.

**Constraints:** one source per submission, one contribution per user per vote item. File types: JPEG, PNG, PDF (max 10 MB). Files upload direct-to-cloud via signed URLs.

### Post-submit — pending approval (AR8)

After tapping "Add This": the sheet dismisses and the new source appears on the vote item at 45% opacity with a "Pending approval" badge (AR8). This pending state is visible only to the contributor — other users do not see the source until approved.

### Display (post-approval)

- Proposer's source(s) appear first, visually distinguished (e.g. "Official source" label)
- Community sources follow, sorted by upvotes descending
- Upvote button on each card (heart or thumbs up)
- "Add research" CTA remains at end of source section

### Data model

```ts
export type VoteSource = {
  ...existing fields...
  // Community contribution metadata
  contributedBy?: string;        // userId — undefined = proposer's own source
  status:         'pending' | 'approved' | 'rejected';
  upvotes?:       number;
  // Admin review
  reviewedBy?:    string;        // admin userId
  reviewedAt?:    string;        // ISO timestamp
  aiFlags?:       AIFlag[];
};
```

### Relationship to comments
A contributed source is distinct from a comment — it's structured (URL or file), searchable, and rankable. Comments are freeform text. Both can coexist in the vote item detail.

---

## Admin Moderation — Research Queue

All community-contributed research goes through admin approval before becoming publicly visible. This mirrors the Proposals queue workflow (see `admin.md`).

### Queue columns

| Column | Description |
|---|---|
| Vote title | The vote item the source was submitted against |
| Source type | Link or File |
| URL / file | The submitted link or uploaded file |
| OG preview | Title, description, domain (for links) |
| Submitted by | Contributor's verified name + user ID |
| Submitted at | Timestamp |
| AI flags | Automated flags (see below) |
| Status | `pending` / `approved` / `rejected` |

### Editor actions

| Action | Result |
|---|---|
| **Approve** | Source becomes publicly visible on the vote item |
| **Reject** | Source removed; contributor sees "Not approved" status |
| **Reject + flag user** | Source rejected and user flagged for pattern review |

### Status flow

```
pending → approved → (visible on vote item)
        → rejected → (hidden; contributor notified)
```

### Schema additions

```sql
ALTER TABLE vote_sources ADD COLUMN status text NOT NULL DEFAULT 'pending'
  CHECK (status IN ('pending', 'approved', 'rejected'));
ALTER TABLE vote_sources ADD COLUMN contributed_by uuid REFERENCES profiles(id);
ALTER TABLE vote_sources ADD COLUMN reviewed_by uuid REFERENCES admin_users(id);
ALTER TABLE vote_sources ADD COLUMN reviewed_at timestamptz;
ALTER TABLE vote_sources ADD COLUMN ai_flags jsonb DEFAULT '[]';
```

---

## AI-Assisted Flagging (Phase 1)

Automated flagging runs at submission time and surfaces warnings in the admin queue. Flags are informational only — an editor always makes the final call.

### Flag types

| Flag | Trigger | Severity |
|---|---|---|
| `domain_suspect` | Domain on soft-watch list (not blacklisted, but low-trust) | Medium |
| `content_mismatch` | OG metadata topic appears unrelated to the vote item's topic/category | Medium |
| `duplicate_source` | URL or file hash matches an existing source on this vote item | High |
| `user_pattern` | User has 2+ prior rejections | High |
| `file_suspect` | Uploaded file fails integrity checks (e.g. PDF with no text, image with embedded text overlay) | Medium |
| `rapid_submission` | User submitted research to 3+ vote items within 10 minutes | Low |

### Display in admin queue

Flagged submissions surface with a colored indicator: red dot (high, sorted to top), yellow dot (medium), grey dot (low, informational). Each flag includes a short explanation string.

### Data structure

```ts
type AIFlag = {
  type: 'domain_suspect' | 'content_mismatch' | 'duplicate_source'
       | 'user_pattern' | 'file_suspect' | 'rapid_submission';
  severity: 'low' | 'medium' | 'high';
  message: string;
  confidence?: number;   // 0–1 for ML-based flags
};
```

---

## Domain Trust & Blacklisting

Source link submissions are validated against a domain blacklist before being accepted. This is the primary quality control mechanism for community-contributed research.

### Domain blacklist
- Maintained server-side — not shipped in the client
- Applied at submission time in Propose a Vote and Add Research flows
- Blocked submissions show inline error: "This source domain is not permitted"
- Covers known misinformation sites, partisan advocacy domains, and low-credibility aggregators
- Updated by moderators — no user input into the blacklist

### User blacklist (future)
Admins can suspend research privileges for specific users. A blacklisted user tapping "Add Research" sees: "Your research privileges have been suspended." No bottom sheet opens. Does not affect their ability to vote or view content.

### Proactive messaging ladder (future)
- **After first rejection:** "Your research submission for [vote title] was not approved. Sources should be specific articles or documents from credible publishers."
- **After second rejection:** "Two of your recent research submissions were not approved. Please review our source guidelines before submitting again."
- **On suspension:** "Your ability to submit research has been suspended. If you believe this is an error, contact support."

Messages delivered as in-app notifications (not push). Implementation deferred — `ai_flags` and rejection history provide the data foundation.

### Domain trust signals (display only, not blocking)
Rather than a strict whitelist, trusted domains get a visual trust signal on the source card: `.gov` → government badge, `.edu` → academic badge, known news organizations → press badge, all others → neutral.

```ts
function isDomainAllowed(url: string): boolean {
  const domain = extractDomain(url);
  return !DOMAIN_BLACKLIST.includes(domain);
}

function domainTrustLevel(domain: string): 'gov' | 'edu' | 'press' | 'neutral' {
  if (domain.endsWith('.gov')) return 'gov';
  if (domain.endsWith('.edu')) return 'edu';
  if (KNOWN_PRESS_DOMAINS.includes(domain)) return 'press';
  return 'neutral';
}
```

---

## Open Questions

- File uploads: direct-to-cloud via signed URLs (S3 or Cloudflare R2) — client uploads directly, server stores resulting URL. Placeholder URLs used in mock data.
- Rejection notification UX — should rejected sources show "Not approved" badge or disappear entirely?
- Re-submission after rejection — can a user submit a different source to the same vote after rejection, or is the slot consumed?
- Upvoting community sources — in scope for launch or deferred?
- Rate limiting — beyond the `rapid_submission` flag, hard limit (e.g. max 5 research submissions per day)?
