Write

# HeapOverflow — Design Spec

**Date:** 2026-05-07
**Status:** Approved

## Overview

HeapOverflow is an internal StackOverflow for Teams clone. It imports an existing SO Teams export (401 questions, 107 users, 117 tags, 2 articles) and allows users to continue posting questions, answers, and comments. It is distributed as a single Go binary that embeds the Angular frontend.

---

## Architecture

### Repo Structure

```
heapoverflow/
├── cmd/
│   ├── server/main.go       ← HTTP server entrypoint
│   └── importer/main.go     ← JSON import CLI
├── internal/
│   ├── api/                 ← REST handlers
│   ├── auth/                ← Okta OIDC + static user
│   ├── store/               ← DB queries (pgx)
│   └── importer/            ← JSON import logic
├── migrations/              ← SQL migration files (sequential numbered)
├── ui/                      ← Angular app (consumes pxl-components)
├── scripts/
│   └── bootstrap.sh         ← spins up Docker postgres, runs migrations, imports data
├── Makefile
└── go.mod
```

### Runtime

- **Single binary**: Go server embeds `ui/dist/**` via `//go:embed`. Requests to `/api/v1/*` are handled by REST handlers; all other requests serve the embedded Angular SPA.
- **Database**: PostgreSQL. Docker container for local dev; managed instance for production.
- **Auth**: Okta OIDC for production. Static test user (toggled by `AUTH_MODE=static`) for local development.

### Dev Workflow

```
make dev
```
1. `scripts/bootstrap.sh` — starts Docker Postgres, runs migrations, imports JSON data (idempotent)
2. `go run ./cmd/server` — API on `:8080`
3. `ng serve` — Angular on `:4200`, proxies `/api` → `:8080`

### Build Workflow

```
make build
```
1. `ng build --configuration production` → `ui/dist/`
2. `go build ./cmd/server` — embeds `ui/dist/`, produces `./heapoverflow` binary
3. Binary reads all config from environment variables

---

## Data Model

### Tables

**users**
| Column | Type | Notes |
|--------|------|-------|
| id | SERIAL PK | |
| user_id | INT UNIQUE | SO Teams export id |
| account_id | INT UNIQUE | SO account id |
| okta_id | TEXT UNIQUE | set on first Okta login |
| display_name | TEXT | |
| email | TEXT UNIQUE | |
| profile_image | TEXT | |
| about_me | TEXT | |
| reputation | INT | imported; earning logic in v2 |
| badge_bronze | INT | imported; earning logic in v2 |
| badge_silver | INT | imported; earning logic in v2 |
| badge_gold | INT | imported; earning logic in v2 |
| is_employee | BOOL | |
| created_at | TIMESTAMPTZ | |
| last_access_at | TIMESTAMPTZ | |

**questions**
| Column | Type | Notes |
|--------|------|-------|
| id | SERIAL PK | |
| legacy_id | INT UNIQUE | SO question_id |
| user_id | INT FK→users | |
| title | TEXT | |
| body_markdown | TEXT | |
| body_html | TEXT | pre-rendered at write time |
| is_answered | BOOL | |
| is_deleted | BOOL | |
| is_obsolete | BOOL | |
| view_count | INT | |
| score | INT | |
| up_vote_count | INT | cached; updated when votes table changes |
| down_vote_count | INT | cached; updated when votes table changes |
| accepted_answer_id | INT FK→answers | nullable |
| search_vector | TSVECTOR | maintained by trigger, GIN indexed |
| created_at | TIMESTAMPTZ | |
| updated_at | TIMESTAMPTZ | |

**answers**
| Column | Type | Notes |
|--------|------|-------|
| id | SERIAL PK | |
| legacy_id | INT UNIQUE | |
| question_id | INT FK→questions | |
| user_id | INT FK→users | |
| body_markdown | TEXT | |
| body_html | TEXT | pre-rendered at write time |
| is_accepted | BOOL | |
| score | INT | |
| up_vote_count | INT | cached |
| down_vote_count | INT | cached |
| created_at | TIMESTAMPTZ | |
| updated_at | TIMESTAMPTZ | |

**comments**
| Column | Type | Notes |
|--------|------|-------|
| id | SERIAL PK | |
| user_id | INT FK→users | |
| post_id | INT | polymorphic |
| post_type | TEXT | `'question'` or `'answer'` |
| body_markdown | TEXT | |
| created_at | TIMESTAMPTZ | |

**tags**
| Column | Type | Notes |
|--------|------|-------|
| id | SERIAL PK | |
| legacy_id | INT UNIQUE | |
| name | TEXT UNIQUE | |
| description | TEXT | |
| count | INT | |
| is_moderator_only | BOOL | |
| is_required | BOOL | |

**votes**
| Column | Type | Notes |
|--------|------|-------|
| id | SERIAL PK | |
| user_id | INT FK→users | |
| post_id | INT | polymorphic |
| post_type | TEXT | `'question'` or `'answer'` |
| vote_type | SMALLINT | `1` (up) or `-1` (down) |
| created_at | TIMESTAMPTZ | |
| | UNIQUE | `(post_id, post_type, user_id)` — one vote per user per post |

**articles**
| Column | Type | Notes |
|--------|------|-------|
| id | SERIAL PK | |
| legacy_id | INT UNIQUE | |
| user_id | INT FK→users | |
| title | TEXT | |
| body_markdown | TEXT | |
| body_html | TEXT | pre-rendered at write time |
| article_type | TEXT | e.g. `knowledge-article` |
| view_count | INT | |
| score | INT | |
| search_vector | TSVECTOR | GIN indexed |
| created_at | TIMESTAMPTZ | |
| updated_at | TIMESTAMPTZ | |

**Junction tables**: `question_tags (question_id, tag_id)`, `article_tags (article_id, tag_id)`, `tag_synonyms (tag_id, synonym TEXT)`, `tag_smes (tag_id, user_id)`.

### Import Strategy

`make import` runs `go run ./cmd/importer`, loading all JSON files in dependency order:

```
users → tags → questions → answers → comments → articles
```

Records are upserted by `legacy_id` — the import is idempotent and safe to re-run. Reputation, badge counts, and cached vote counts are imported as-is from `users.json`. The `votes` table is not populated from the import (only totals are available in the export); new votes after import are tracked individually.

---

## REST API

Base path: `/api/v1`

### Auth
| Method | Path | Description |
|--------|------|-------------|
| GET | `/auth/login` | Redirect to Okta authorization URL |
| GET | `/auth/callback` | Exchange code, set HTTP-only session cookie |
| POST | `/auth/logout` | Clear session |
| GET | `/auth/me` | Return current authenticated user |

### Questions
| Method | Path | Description |
|--------|------|-------------|
| GET | `/questions` | List questions. Query params: `page`, `sort` (newest/active/votes/unanswered), `tag`, `q` |
| POST | `/questions` | Create question |
| GET | `/questions/:id` | Get question with answers and comments |
| PUT | `/questions/:id` | Edit question (author or admin) |
| DELETE | `/questions/:id` | Soft-delete question (sets `is_deleted=true`) |
| POST | `/questions/:id/vote` | Cast or retract vote. Body: `{"vote_type": 1}` or `{"vote_type": -1}` |

### Answers
| Method | Path | Description |
|--------|------|-------------|
| POST | `/questions/:id/answers` | Post answer |
| PUT | `/answers/:id` | Edit answer (author or admin) |
| DELETE | `/answers/:id` | Delete answer |
| POST | `/answers/:id/vote` | Cast or retract vote |
| POST | `/answers/:id/accept` | Accept answer (question author only) |

### Comments
| Method | Path | Description |
|--------|------|-------------|
| POST | `/questions/:id/comments` | Add comment to question |
| POST | `/answers/:id/comments` | Add comment to answer |
| DELETE | `/comments/:id` | Delete comment (author or admin) |

### Tags
| Method | Path | Description |
|--------|------|-------------|
| GET | `/tags` | List tags. Query param: `q` (search by name) |
| GET | `/tags/:name` | Get tag detail with SMEs and question count |

### Users
| Method | Path | Description |
|--------|------|-------------|
| GET | `/users/:id` | Get user profile |
| PUT | `/users/me` | Update own profile |

### Articles
| Method | Path | Description |
|--------|------|-------------|
| GET | `/articles` | List articles |
| GET | `/articles/:id` | Get article detail |

### Search
| Method | Path | Description |
|--------|------|-------------|
| GET | `/search` | Full-text search. Query param: `q`. Returns ranked questions + articles |

---

## Authentication

### Okta OIDC (production)
1. Angular `AuthGuard` redirects unauthenticated users to `/api/v1/auth/login`
2. Go redirects to Okta authorization URL
3. Okta redirects to `/api/v1/auth/callback` with authorization code
4. Go exchanges code for tokens, looks up or creates user record (matching on `okta_id` / email), sets an **HTTP-only session cookie**
5. Go redirects to Angular `/auth/callback`; Angular calls `/auth/me` to hydrate current user state

### Static user (local dev / testing)
- Enabled when `AUTH_MODE=static` is set (automatically disabled when `OKTA_*` env vars are present)
- Login page shows a **"Dev Login"** button that sets a session as a pre-configured test user from the database
- No Okta credentials required for local development

---

## Search

Full-text search is implemented using PostgreSQL's built-in `tsvector`:
- A `search_vector` column on `questions` and `articles` is maintained by a database trigger
- Indexed with a GIN index for fast lookups
- `GET /search?q=` queries both tables, ranks results by relevance, and returns a merged list
- Sufficient for 400+ questions without requiring an external search service

---

## Frontend (Angular)

The Angular app lives in `ui/` and imports components from the `pxl-components` library.

### Routes
| Path | Component | Guard |
|------|-----------|-------|
| `/login` | LoginComponent | — |
| `/auth/callback` | AuthCallbackComponent | — |
| `/` | redirect → `/questions` | AuthGuard |
| `/questions` | QuestionListComponent | AuthGuard |
| `/questions/ask` | AskQuestionComponent | AuthGuard |
| `/questions/:id` | QuestionDetailComponent | AuthGuard |
| `/tags` | TagListComponent | AuthGuard |
| `/tags/:name` | TagDetailComponent | AuthGuard |
| `/articles` | ArticleListComponent | AuthGuard |
| `/articles/:id` | ArticleDetailComponent | AuthGuard |
| `/users/:id` | UserProfileComponent | AuthGuard |
| `/search` | SearchResultsComponent | AuthGuard |

### pxl-components Usage
- `VerticalNav` — sidebar navigation
- `PageHeader` — top navigation bar with search
- `SearchInput` — global search field
- `Breadcrumbs` — question/article detail pages
- `Chips` + `ChipFilter` — sort tabs and tag filters
- `Cards` — question list rows
- `Alert` — error and notice banners
- `SkeletonLoader` — loading states
- `FormField` — ask question / post answer forms
- `EmptyState` — no results views
- `StatusIndicator` — answered/unanswered badge
- `Tooltip` — vote count details

---

## Out of Scope (v1)

The following are explicitly deferred to future iterations:

- **Badge earning** — badge counts are imported and displayed but not earned through new activity
- **Reputation changes** — reputation is imported as a static value; delta tracking is a v2 concern
- **Article creation/editing** — articles are read-only in v1 (imported only)
- **Admin UI** — moderation is handled directly in the database or via future work
- **Email notifications** — no notification system in v1


File created successfully at: /Users/steve.briggs/projects/heapoverflow/docs/superpowers/specs/2026-05-07-heapoverflow-design.md (file state is current in your context — no need to Read it back)

# HeapOverflow — Design Spec

**Date:** 2026-05-07
**Status:** Approved

## Overview

HeapOverflow is an internal StackOverflow for Teams clone. It imports an existing SO Teams export (401 questions, 107 users, 117 tags, 2 articles) and allows users to continue posting questions, answers, and comments. It is distributed as a single Go binary that embeds the Angular frontend.

---

## Architecture

### Repo Structure

```
heapoverflow/
├── cmd/
│   ├── server/main.go       ← HTTP server entrypoint
│   └── importer/main.go     ← JSON import CLI
├── internal/
│   ├── api/                 ← REST handlers
│   ├── auth/                ← Okta OIDC + static user
│   ├── store/               ← DB queries (pgx)
│   └── importer/            ← JSON import logic
├── migrations/              ← SQL migration files (sequential numbered)
├── ui/                      ← Angular app (consumes pxl-components)
├── scripts/
│   └── bootstrap.sh         ← spins up Docker postgres, runs migrations, imports data
├── Makefile
└── go.mod
```

### Runtime

- **Single binary**: Go server embeds `ui/dist/**` via `//go:embed`. Requests to `/api/v1/*` are handled by REST handlers; all other requests serve the embedded Angular SPA.
- **Database**: PostgreSQL. Docker container for local dev; managed instance for production.
- **Auth**: Okta OIDC for production. Static test user (toggled by `AUTH_MODE=static`) for local development.

### Dev Workflow

```
make dev
```
1. `scripts/bootstrap.sh` — starts Docker Postgres, runs migrations, imports JSON data (idempotent)
2. `go run ./cmd/server` — API on `:8080`
3. `ng serve` — Angular on `:4200`, proxies `/api` → `:8080`

### Build Workflow

```
make build
```
1. `ng build --configuration production` → `ui/dist/`
2. `go build ./cmd/server` — embeds `ui/dist/`, produces `./heapoverflow` binary
3. Binary reads all config from environment variables

---

## Data Model

### Tables

**users**
| Column | Type | Notes |
|--------|------|-------|
| id | SERIAL PK | |
| user_id | INT UNIQUE | SO Teams export id |
| account_id | INT UNIQUE | SO account id |
| okta_id | TEXT UNIQUE | set on first Okta login |
| display_name | TEXT | |
| email | TEXT UNIQUE | |
| profile_image | TEXT | |
| about_me | TEXT | |
| reputation | INT | imported; earning logic in v2 |
| badge_bronze | INT | imported; earning logic in v2 |
| badge_silver | INT | imported; earning logic in v2 |
| badge_gold | INT | imported; earning logic in v2 |
| is_employee | BOOL | |
| created_at | TIMESTAMPTZ | |
| last_access_at | TIMESTAMPTZ | |

**questions**
| Column | Type | Notes |
|--------|------|-------|
| id | SERIAL PK | |
| legacy_id | INT UNIQUE | SO question_id |
| user_id | INT FK→users | |
| title | TEXT | |
| body_markdown | TEXT | |
| body_html | TEXT | pre-rendered at write time |
| is_answered | BOOL | |
| is_deleted | BOOL | |
| is_obsolete | BOOL | |
| view_count | INT | |
| score | INT | |
| up_vote_count | INT | cached; updated when votes table changes |
| down_vote_count | INT | cached; updated when votes table changes |
| accepted_answer_id | INT FK→answers | nullable |
| search_vector | TSVECTOR | maintained by trigger, GIN indexed |
| created_at | TIMESTAMPTZ | |
| updated_at | TIMESTAMPTZ | |

**answers**
| Column | Type | Notes |
|--------|------|-------|
| id | SERIAL PK | |
| legacy_id | INT UNIQUE | |
| question_id | INT FK→questions | |
| user_id | INT FK→users | |
| body_markdown | TEXT | |
| body_html | TEXT | pre-rendered at write time |
| is_accepted | BOOL | |
| score | INT | |
| up_vote_count | INT | cached |
| down_vote_count | INT | cached |
| created_at | TIMESTAMPTZ | |
| updated_at | TIMESTAMPTZ | |

**comments**
| Column | Type | Notes |
|--------|------|-------|
| id | SERIAL PK | |
| user_id | INT FK→users | |
| post_id | INT | polymorphic |
| post_type | TEXT | `'question'` or `'answer'` |
| body_markdown | TEXT | |
| created_at | TIMESTAMPTZ | |

**tags**
| Column | Type | Notes |
|--------|------|-------|
| id | SERIAL PK | |
| legacy_id | INT UNIQUE | |
| name | TEXT UNIQUE | |
| description | TEXT | |
| count | INT | |
| is_moderator_only | BOOL | |
| is_required | BOOL | |

**votes**
| Column | Type | Notes |
|--------|------|-------|
| id | SERIAL PK | |
| user_id | INT FK→users | |
| post_id | INT | polymorphic |
| post_type | TEXT | `'question'` or `'answer'` |
| vote_type | SMALLINT | `1` (up) or `-1` (down) |
| created_at | TIMESTAMPTZ | |
| | UNIQUE | `(post_id, post_type, user_id)` — one vote per user per post |

**articles**
| Column | Type | Notes |
|--------|------|-------|
| id | SERIAL PK | |
| legacy_id | INT UNIQUE | |
| user_id | INT FK→users | |
| title | TEXT | |
| body_markdown | TEXT | |
| body_html | TEXT | pre-rendered at write time |
| article_type | TEXT | e.g. `knowledge-article` |
| view_count | INT | |
| score | INT | |
| search_vector | TSVECTOR | GIN indexed |
| created_at | TIMESTAMPTZ | |
| updated_at | TIMESTAMPTZ | |

**Junction tables**: `question_tags (question_id, tag_id)`, `article_tags (article_id, tag_id)`, `tag_synonyms (tag_id, synonym TEXT)`, `tag_smes (tag_id, user_id)`.

### Import Strategy

`make import` runs `go run ./cmd/importer`, loading all JSON files in dependency order:

```
users → tags → questions → answers → comments → articles
```

Records are upserted by `legacy_id` — the import is idempotent and safe to re-run. Reputation, badge counts, and cached vote counts are imported as-is from `users.json`. The `votes` table is not populated from the import (only totals are available in the export); new votes after import are tracked individually.

---

## REST API

Base path: `/api/v1`

### Auth
| Method | Path | Description |
|--------|------|-------------|
| GET | `/auth/login` | Redirect to Okta authorization URL |
| GET | `/auth/callback` | Exchange code, set HTTP-only session cookie |
| POST | `/auth/logout` | Clear session |
| GET | `/auth/me` | Return current authenticated user |

### Questions
| Method | Path | Description |
|--------|------|-------------|
| GET | `/questions` | List questions. Query params: `page`, `sort` (newest/active/votes/unanswered), `tag`, `q` |
| POST | `/questions` | Create question |
| GET | `/questions/:id` | Get question with answers and comments |
| PUT | `/questions/:id` | Edit question (author or admin) |
| DELETE | `/questions/:id` | Soft-delete question (sets `is_deleted=true`) |
| POST | `/questions/:id/vote` | Cast or retract vote. Body: `{"vote_type": 1}` or `{"vote_type": -1}` |

### Answers
| Method | Path | Description |
|--------|------|-------------|
| POST | `/questions/:id/answers` | Post answer |
| PUT | `/answers/:id` | Edit answer (author or admin) |
| DELETE | `/answers/:id` | Delete answer |
| POST | `/answers/:id/vote` | Cast or retract vote |
| POST | `/answers/:id/accept` | Accept answer (question author only) |

### Comments
| Method | Path | Description |
|--------|------|-------------|
| POST | `/questions/:id/comments` | Add comment to question |
| POST | `/answers/:id/comments` | Add comment to answer |
| DELETE | `/comments/:id` | Delete comment (author or admin) |

### Tags
| Method | Path | Description |
|--------|------|-------------|
| GET | `/tags` | List tags. Query param: `q` (search by name) |
| GET | `/tags/:name` | Get tag detail with SMEs and question count |

### Users
| Method | Path | Description |
|--------|------|-------------|
| GET | `/users/:id` | Get user profile |
| PUT | `/users/me` | Update own profile |

### Articles
| Method | Path | Description |
|--------|------|-------------|
| GET | `/articles` | List articles |
| GET | `/articles/:id` | Get article detail |

### Search
| Method | Path | Description |
|--------|------|-------------|
| GET | `/search` | Full-text search. Query param: `q`. Returns ranked questions + articles |

---

## Authentication

### Okta OIDC (production)
1. Angular `AuthGuard` redirects unauthenticated users to `/api/v1/auth/login`
2. Go redirects to Okta authorization URL
3. Okta redirects to `/api/v1/auth/callback` with authorization code
4. Go exchanges code for tokens, looks up or creates user record (matching on `okta_id` / email), sets an **HTTP-only session cookie**
5. Go redirects to Angular `/auth/callback`; Angular calls `/auth/me` to hydrate current user state

### Static user (local dev / testing)
- Enabled when `AUTH_MODE=static` is set (automatically disabled when `OKTA_*` env vars are present)
- Login page shows a **"Dev Login"** button that sets a session as a pre-configured test user from the database
- No Okta credentials required for local development

---

## Search

Full-text search is implemented using PostgreSQL's built-in `tsvector`:
- A `search_vector` column on `questions` and `articles` is maintained by a database trigger
- Indexed with a GIN index for fast lookups
- `GET /search?q=` queries both tables, ranks results by relevance, and returns a merged list
- Sufficient for 400+ questions without requiring an external search service

---

## Frontend (Angular)

The Angular app lives in `ui/` and imports components from the `pxl-components` library.

### Routes
| Path | Component | Guard |
|------|-----------|-------|
| `/login` | LoginComponent | — |
| `/auth/callback` | AuthCallbackComponent | — |
| `/` | redirect → `/questions` | AuthGuard |
| `/questions` | QuestionListComponent | AuthGuard |
| `/questions/ask` | AskQuestionComponent | AuthGuard |
| `/questions/:id` | QuestionDetailComponent | AuthGuard |
| `/tags` | TagListComponent | AuthGuard |
| `/tags/:name` | TagDetailComponent | AuthGuard |
| `/articles` | ArticleListComponent | AuthGuard |
| `/articles/:id` | ArticleDetailComponent | AuthGuard |
| `/users/:id` | UserProfileComponent | AuthGuard |
| `/search` | SearchResultsComponent | AuthGuard |

### pxl-components Usage
- `VerticalNav` — sidebar navigation
- `PageHeader` — top navigation bar with search
- `SearchInput` — global search field
- `Breadcrumbs` — question/article detail pages
- `Chips` + `ChipFilter` — sort tabs and tag filters
- `Cards` — question list rows
- `Alert` — error and notice banners
- `SkeletonLoader` — loading states
- `FormField` — ask question / post answer forms
- `EmptyState` — no results views
- `StatusIndicator` — answered/unanswered badge
- `Tooltip` — vote count details

---

## Out of Scope (v1)

The following are explicitly deferred to future iterations:

- **Badge earning** — badge counts are imported and displayed but not earned through new activity
- **Reputation changes** — reputation is imported as a static value; delta tracking is a v2 concern
- **Article creation/editing** — articles are read-only in v1 (imported only)
- **Admin UI** — moderation is handled directly in the database or via future work
- **Email notifications** — no notification system in v1


Build Stack Overflow Teams clone with pxl-components


Updated task #6 status


Now running the spec self-review.


Updated task #7 status


1	# HeapOverflow — Design Spec
2
3	**Date:** 2026-05-07
4	**Status:** Approved
5
6	## Overview
7
8	HeapOverflow is an internal StackOverflow for Teams clone. It imports an existing SO Teams export (401 questions, 107 users, 117 tags, 2 articles) and allows users to continue posting questions, answers, and comments. It is distributed as a single Go binary that embeds the Angular frontend.
9
10	---
11
12	## Architecture
13
14	### Repo Structure
15
16	```
17	heapoverflow/
18	├── cmd/
19	│   ├── server/main.go       ← HTTP server entrypoint
20	│   └── importer/main.go     ← JSON import CLI
21	├── internal/
22	│   ├── api/                 ← REST handlers
23	│   ├── auth/                ← Okta OIDC + static user
24	│   ├── store/               ← DB queries (pgx)
25	│   └── importer/            ← JSON import logic
26	├── migrations/              ← SQL migration files (sequential numbered)
27	├── ui/                      ← Angular app (consumes pxl-components)
28	├── scripts/
29	│   └── bootstrap.sh         ← spins up Docker postgres, runs migrations, imports data
30	├── Makefile
31	└── go.mod
32	```
33
34	### Runtime
35
36	- **Single binary**: Go server embeds `ui/dist/**` via `//go:embed`. Requests to `/api/v1/*` are handled by REST handlers; all other requests serve the embedded Angular SPA.
37	- **Database**: PostgreSQL. Docker container for local dev; managed instance for production.
38	- **Auth**: Okta OIDC for production. Static test user (toggled by `AUTH_MODE=static`) for local development.
39
40	### Dev Workflow
41
42	```
43	make dev
44	```
45	1. `scripts/bootstrap.sh` — starts Docker Postgres, runs migrations, imports JSON data (idempotent)
46	2. `go run ./cmd/server` — API on `:8080`
47	3. `ng serve` — Angular on `:4200`, proxies `/api` → `:8080`
48
49	### Build Workflow
50
51	```
52	make build
53	```
54	1. `ng build --configuration production` → `ui/dist/`
55	2. `go build ./cmd/server` — embeds `ui/dist/`, produces `./heapoverflow` binary
56	3. Binary reads all config from environment variables
57
58	---
59
60	## Data Model
61
62	### Tables
63
64	**users**
65	| Column | Type | Notes |
66	|--------|------|-------|
67	| id | SERIAL PK | |
68	| user_id | INT UNIQUE | SO Teams export id |
69	| account_id | INT UNIQUE | SO account id |
70	| okta_id | TEXT UNIQUE | set on first Okta login |
71	| display_name | TEXT | |
72	| email | TEXT UNIQUE | |
73	| profile_image | TEXT | |
74	| about_me | TEXT | |
75	| reputation | INT | imported; earning logic in v2 |
76	| badge_bronze | INT | imported; earning logic in v2 |
77	| badge_silver | INT | imported; earning logic in v2 |
78	| badge_gold | INT | imported; earning logic in v2 |
79	| is_employee | BOOL | |
80	| created_at | TIMESTAMPTZ | |
81	| last_access_at | TIMESTAMPTZ | |
82
83	**questions**
84	| Column | Type | Notes |
85	|--------|------|-------|
86	| id | SERIAL PK | |
87	| legacy_id | INT UNIQUE | SO question_id |
88	| user_id | INT FK→users | |
89	| title | TEXT | |
90	| body_markdown | TEXT | |
91	| body_html | TEXT | pre-rendered at write time |
92	| is_answered | BOOL | |
93	| is_deleted | BOOL | |
94	| is_obsolete | BOOL | |
95	| view_count | INT | |
96	| score | INT | |
97	| up_vote_count | INT | cached; updated when votes table changes |
98	| down_vote_count | INT | cached; updated when votes table changes |
99	| accepted_answer_id | INT FK→answers | nullable |
100	| search_vector | TSVECTOR | maintained by trigger, GIN indexed |
101	| created_at | TIMESTAMPTZ | |
102	| updated_at | TIMESTAMPTZ | |
103
104	**answers**
105	| Column | Type | Notes |
106	|--------|------|-------|
107	| id | SERIAL PK | |
108	| legacy_id | INT UNIQUE | |
109	| question_id | INT FK→questions | |
110	| user_id | INT FK→users | |
111	| body_markdown | TEXT | |
112	| body_html | TEXT | pre-rendered at write time |
113	| is_accepted | BOOL | |
114	| score | INT | |
115	| up_vote_count | INT | cached |
116	| down_vote_count | INT | cached |
117	| created_at | TIMESTAMPTZ | |
118	| updated_at | TIMESTAMPTZ | |
119
120	**comments**
121	| Column | Type | Notes |
122	|--------|------|-------|
123	| id | SERIAL PK | |
124	| user_id | INT FK→users | |
125	| post_id | INT | polymorphic |
126	| post_type | TEXT | `'question'` or `'answer'` |
127	| body_markdown | TEXT | |
128	| created_at | TIMESTAMPTZ | |
129
130	**tags**
131	| Column | Type | Notes |
132	|--------|------|-------|
133	| id | SERIAL PK | |
134	| legacy_id | INT UNIQUE | |
135	| name | TEXT UNIQUE | |
136	| description | TEXT | |
137	| count | INT | |
138	| is_moderator_only | BOOL | |
139	| is_required | BOOL | |
140
141	**votes**
142	| Column | Type | Notes |
143	|--------|------|-------|
144	| id | SERIAL PK | |
145	| user_id | INT FK→users | |
146	| post_id | INT | polymorphic |
147	| post_type | TEXT | `'question'` or `'answer'` |
148	| vote_type | SMALLINT | `1` (up) or `-1` (down) |
149	| created_at | TIMESTAMPTZ | |
150	| | UNIQUE | `(post_id, post_type, user_id)` — one vote per user per post |
151
152	**articles**
153	| Column | Type | Notes |
154	|--------|------|-------|
155	| id | SERIAL PK | |
156	| legacy_id | INT UNIQUE | |
157	| user_id | INT FK→users | |
158	| title | TEXT | |
159	| body_markdown | TEXT | |
160	| body_html | TEXT | pre-rendered at write time |
161	| article_type | TEXT | e.g. `knowledge-article` |
162	| view_count | INT | |
163	| score | INT | |
164	| search_vector | TSVECTOR | GIN indexed |
165	| created_at | TIMESTAMPTZ | |
166	| updated_at | TIMESTAMPTZ | |
167
168	**Junction tables**: `question_tags (question_id, tag_id)`, `article_tags (article_id, tag_id)`, `tag_synonyms (tag_id, synonym TEXT)`, `tag_smes (tag_id, user_id)`.
169
170	### Import Strategy
171
172	`make import` runs `go run ./cmd/importer`, loading all JSON files in dependency order:
173
174	```
175	users → tags → questions → answers → comments → articles
176	```
177
178	Records are upserted by `legacy_id` — the import is idempotent and safe to re-run. Reputation, badge counts, and cached vote counts are imported as-is from `users.json`. The `votes` table is not populated from the import (only totals are available in the export); new votes after import are tracked individually.
179
180	---
181
182	## REST API
183
184	Base path: `/api/v1`
185
186	### Auth
187	| Method | Path | Description |
188	|--------|------|-------------|
189	| GET | `/auth/login` | Redirect to Okta authorization URL |
190	| GET | `/auth/callback` | Exchange code, set HTTP-only session cookie |
191	| POST | `/auth/logout` | Clear session |
192	| GET | `/auth/me` | Return current authenticated user |
193
194	### Questions
195	| Method | Path | Description |
196	|--------|------|-------------|
197	| GET | `/questions` | List questions. Query params: `page`, `sort` (newest/active/votes/unanswered), `tag`, `q` |
198	| POST | `/questions` | Create question |
199	| GET | `/questions/:id` | Get question with answers and comments |
200	| PUT | `/questions/:id` | Edit question (author or admin) |
201	| DELETE | `/questions/:id` | Soft-delete question (sets `is_deleted=true`) |
202	| POST | `/questions/:id/vote` | Cast or retract vote. Body: `{"vote_type": 1}` or `{"vote_type": -1}` |
203
204	### Answers
205	| Method | Path | Description |
206	|--------|------|-------------|
207	| POST | `/questions/:id/answers` | Post answer |
208	| PUT | `/answers/:id` | Edit answer (author or admin) |
209	| DELETE | `/answers/:id` | Delete answer |
210	| POST | `/answers/:id/vote` | Cast or retract vote |
211	| POST | `/answers/:id/accept` | Accept answer (question author only) |
212
213	### Comments
214	| Method | Path | Description |
215	|--------|------|-------------|
216	| POST | `/questions/:id/comments` | Add comment to question |
217	| POST | `/answers/:id/comments` | Add comment to answer |
218	| DELETE | `/comments/:id` | Delete comment (author or admin) |
219
220	### Tags
221	| Method | Path | Description |
222	|--------|------|-------------|
223	| GET | `/tags` | List tags. Query param: `q` (search by name) |
224	| GET | `/tags/:name` | Get tag detail with SMEs and question count |
225
226	### Users
227	| Method | Path | Description |
228	|--------|------|-------------|
229	| GET | `/users/:id` | Get user profile |
230	| PUT | `/users/me` | Update own profile |
231
232	### Articles
233	| Method | Path | Description |
234	|--------|------|-------------|
235	| GET | `/articles` | List articles |
236	| GET | `/articles/:id` | Get article detail |
237
238	### Search
239	| Method | Path | Description |
240	|--------|------|-------------|
241	| GET | `/search` | Full-text search. Query param: `q`. Returns ranked questions + articles |
242
243	---
244
245	## Authentication
246
247	### Okta OIDC (production)
248	1. Angular `AuthGuard` redirects unauthenticated users to `/api/v1/auth/login`
249	2. Go redirects to Okta authorization URL
250	3. Okta redirects to `/api/v1/auth/callback` with authorization code
251	4. Go exchanges code for tokens, looks up or creates user record (matching on `okta_id` / email), sets an **HTTP-only session cookie**
252	5. Go redirects to Angular `/auth/callback`; Angular calls `/auth/me` to hydrate current user state
253
254	### Static user (local dev / testing)
255	- Enabled when `AUTH_MODE=static` is set (automatically disabled when `OKTA_*` env vars are present)
256	- Login page shows a **"Dev Login"** button that sets a session as a pre-configured test user from the database
257	- No Okta credentials required for local development
258
259	---
260
261	## Search
262
263	Full-text search is implemented using PostgreSQL's built-in `tsvector`:
264	- A `search_vector` column on `questions` and `articles` is maintained by a database trigger
265	- Indexed with a GIN index for fast lookups
266	- `GET /search?q=` queries both tables, ranks results by relevance, and returns a merged list
267	- Sufficient for 400+ questions without requiring an external search service
268
269	---
270
271	## Frontend (Angular)
272
273	The Angular app lives in `ui/` and imports components from the `pxl-components` library.
274
275	### Routes
276	| Path | Component | Guard |
277	|------|-----------|-------|
278	| `/login` | LoginComponent | — |
279	| `/auth/callback` | AuthCallbackComponent | — |
280	| `/` | redirect → `/questions` | AuthGuard |
281	| `/questions` | QuestionListComponent | AuthGuard |
282	| `/questions/ask` | AskQuestionComponent | AuthGuard |
283	| `/questions/:id` | QuestionDetailComponent | AuthGuard |
284	| `/tags` | TagListComponent | AuthGuard |
285	| `/tags/:name` | TagDetailComponent | AuthGuard |
286	| `/articles` | ArticleListComponent | AuthGuard |
287	| `/articles/:id` | ArticleDetailComponent | AuthGuard |
288	| `/users/:id` | UserProfileComponent | AuthGuard |
289	| `/search` | SearchResultsComponent | AuthGuard |
290
291	### pxl-components Usage
292	- `VerticalNav` — sidebar navigation
293	- `PageHeader` — top navigation bar with search
294	- `SearchInput` — global search field
295	- `Breadcrumbs` — question/article detail pages
296	- `Chips` + `ChipFilter` — sort tabs and tag filters
297	- `Cards` — question list rows
298	- `Alert` — error and notice banners
299	- `SkeletonLoader` — loading states
300	- `FormField` — ask question / post answer forms
301	- `EmptyState` — no results views
302	- `StatusIndicator` — answered/unanswered badge
303	- `Tooltip` — vote count details
304
305	---
306
307	## Out of Scope (v1)
308
309	The following are explicitly deferred to future iterations:
310
311	- **Badge earning** — badge counts are imported and displayed but not earned through new activity
312	- **Reputation changes** — reputation is imported as a static value; delta tracking is a v2 concern
313	- **Article creation/editing** — articles are read-only in v1 (imported only)
314	- **Admin UI** — moderation is handled directly in the database or via future work
315	- **Email notifications** — no notification system in v1
316


Scanning now — found three things to fix inline before handing to Sir Charles:

1. `legacy_id` on all tables needs to be explicitly nullable — new records won't have one, and the UNIQUE constraint only applies when non-null
2. `comments` is missing `body_html` — inconsistent with questions/answers which pre-render HTML at write time
3. Angular 17+ outputs to `ui/dist/heapoverflow/browser/` not `ui/dist/` — the embed path needs to be accurate


Edit

**Junction tables**: `question_tags (question_id, tag_id)`, `article_tags (article_id, tag_id)`, `tag_synonyms (tag_id, synonym TEXT)`, `tag_smes (tag_id, user_id)`.

**Note on `legacy_id`**: Present on all imported tables (`users`, `questions`, `answers`, `tags`, `articles`). Always nullable — only set for records created via the JSON import. The UNIQUE constraint is `UNIQUE WHERE legacy_id IS NOT NULL`. New records created by users leave `legacy_id` as NULL.

**Junction tables**: `question_tags (question_id, tag_id)`, `article_tags (article_id, tag_id)`, `tag_synonyms (tag_id, synonym TEXT)`, `tag_smes (tag_id, user_id)`.


The file /Users/steve.briggs/projects/heapoverflow/docs/superpowers/specs/2026-05-07-heapoverflow-design.md has been updated successfully. (file state is current in your context — no need to Read it back)

**Note on `legacy_id`**: Present on all imported tables (`users`, `questions`, `answers`, `tags`, `articles`). Always nullable — only set for records created via the JSON import. The UNIQUE constraint is `UNIQUE WHERE legacy_id IS NOT NULL`. New records created by users leave `legacy_id` as NULL.

**Junction tables**: `question_tags (question_id, tag_id)`, `article_tags (article_id, tag_id)`, `tag_synonyms (tag_id, synonym TEXT)`, `tag_smes (tag_id, user_id)`.

+**Note on `legacy_id`**: Present on all imported tables (`users`, `questions`, `answers`, `tags`, `articles`). Always nullable — only set for records created via the JSON import. The UNIQUE constraint is `UNIQUE WHERE legacy_id IS NOT NULL`. New records created by users leave `legacy_id` as NULL.


| body_markdown | TEXT | |
| created_at | TIMESTAMPTZ | |

**tags**

| body_markdown | TEXT | |
| body_html | TEXT | pre-rendered at write time |
| created_at | TIMESTAMPTZ | |

**tags**


| body_markdown | TEXT | |
| created_at | TIMESTAMPTZ | |

**tags**

| body_markdown | TEXT | |
| body_html | TEXT | pre-rendered at write time |
| created_at | TIMESTAMPTZ | |

**tags**

| post_type | TEXT | `'question'` or `'answer'` |

+| body_html | TEXT | pre-rendered at write time |


2. `go build ./cmd/server` — embeds `ui/dist/`, produces `./heapoverflow` binary

2. `go build ./cmd/server` — embeds `ui/dist/heapoverflow/browser/`, produces `./heapoverflow` binary


1. `ng build --configuration production` → `ui/dist/`

-2. `go build ./cmd/server` — embeds `ui/dist/`, produces `./heapoverflow` binary

+2. `go build ./cmd/server` — embeds `ui/dist/heapoverflow/browser/`, produces `./heapoverflow` binary

3. Binary reads all config from environment variables


- **Single binary**: Go server embeds `ui/dist/**` via `//go:embed`. Requests to `/api/v1/*` are handled by REST handlers; all other requests serve the embedded Angular SPA.

- **Single binary**: Go server embeds `ui/dist/heapoverflow/browser/**` via `//go:embed`. Requests to `/api/v1/*` are handled by REST handlers; all other requests serve the embedded Angular SPA.


-- **Single binary**: Go server embeds `ui/dist/**` via `//go:embed`. Requests to `/api/v1/*` are handled by REST handlers; all other requests serve the embedded Angular SPA.

+- **Single binary**: Go server embeds `ui/dist/heapoverflow/browser/**` via `//go:embed`. Requests to `/api/v1/*` are handled by REST handlers; all other requests serve the embedded Angular SPA.

- **Database**: PostgreSQL. Docker container for local dev; managed instance for production.

- **Auth**: Okta OIDC for production. Static test user (toggled by `AUTH_MODE=static`) for local development.


1. `ng build --configuration production` → `ui/dist/`

1. `ng build --configuration production` → `ui/dist/heapoverflow/browser/`


-1. `ng build --configuration production` → `ui/dist/`

+1. `ng build --configuration production` → `ui/dist/heapoverflow/browser/`

2. `go build ./cmd/server` — embeds `ui/dist/heapoverflow/browser/`, produces `./heapoverflow` binary


Updated task #8 status


Spec written and self-reviewed at `docs/superpowers/specs/2026-05-07-heapoverflow-design.md`. Three fixes applied:
- `legacy_id` clarified as nullable with a partial unique index
- `body_html` added to `comments` table
- Angular embed path corrected to `ui/dist/heapoverflow/browser/`

Please review it, Sir Charles, and let me know if you want any changes before we move on to the implementation plan.


afplay ~/.claude/sounds/mario/stop/stomp.wav 2>/dev/null || true


looks good


Updated task #9 status


Launching skill: superpowers:writing-plans


Base directory for this skill: /Users/steve.briggs/.claude/plugins/cache/claude-plugins-official/superpowers/5.1.0/skills/writing-plans

# Writing Plans

## Overview

Write comprehensive implementation plans assuming the engineer has zero context for our codebase and questionable taste. Document everything they need to know: which files to touch for each task, code, testing, docs they might need to check, how to test it. Give them the whole plan as bite-sized tasks. DRY. YAGNI. TDD. Frequent commits.

Assume they are a skilled developer, but know almost nothing about our toolset or problem domain. Assume they don't know good test design very well.

**Announce at start:** "I'm using the writing-plans skill to create the implementation plan."

**Context:** If working in an isolated worktree, it should have been created via the `superpowers:using-git-worktrees` skill at execution time.

**Save plans to:** `docs/superpowers/plans/YYYY-MM-DD-<feature-name>.md`
- (User preferences for plan location override this default)

## Scope Check

If the spec covers multiple independent subsystems, it should have been broken into sub-project specs during brainstorming. If it wasn't, suggest breaking this into separate plans — one per subsystem. Each plan should produce working, testable software on its own.

## File Structure

Before defining tasks, map out which files will be created or modified and what each one is responsible for. This is where decomposition decisions get locked in.

- Design units with clear boundaries and well-defined interfaces. Each file should have one clear responsibility.
- You reason best about code you can hold in context at once, and your edits are more reliable when files are focused. Prefer smaller, focused files over large ones that do too much.
- Files that change together should live together. Split by responsibility, not by technical layer.
- In existing codebases, follow established patterns. If the codebase uses large files, don't unilaterally restructure - but if a file you're modifying has grown unwieldy, including a split in the plan is reasonable.

This structure informs the task decomposition. Each task should produce self-contained changes that make sense independently.

## Bite-Sized Task Granularity

**Each step is one action (2-5 minutes):**
- "Write the failing test" - step
- "Run it to make sure it fails" - step
- "Implement the minimal code to make the test pass" - step
- "Run the tests and make sure they pass" - step
- "Commit" - step

## Plan Document Header

**Every plan MUST start with this header:**

```markdown
# [Feature Name] 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:** [One sentence describing what this builds]

**Architecture:** [2-3 sentences about approach]

**Tech Stack:** [Key technologies/libraries]

---
```

## Task Structure

````markdown
### Task N: [Component Name]

**Files:**
- Create: `exact/path/to/file.py`
- Modify: `exact/path/to/existing.py:123-145`
- Test: `tests/exact/path/to/test.py`

- [ ] **Step 1: Write the failing test**

```python
def test_specific_behavior():
    result = function(input)
    assert result == expected
```

- [ ] **Step 2: Run test to verify it fails**

Run: `pytest tests/path/test.py::test_name -v`
Expected: FAIL with "function not defined"

- [ ] **Step 3: Write minimal implementation**

```python
def function(input):
    return expected
```

- [ ] **Step 4: Run test to verify it passes**

Run: `pytest tests/path/test.py::test_name -v`
Expected: PASS

- [ ] **Step 5: Commit**

```bash
git add tests/path/test.py src/path/file.py
git commit -m "feat: add specific feature"
```
````

## No Placeholders

Every step must contain the actual content an engineer needs. These are **plan failures** — never write them:
- "TBD", "TODO", "implement later", "fill in details"
- "Add appropriate error handling" / "add validation" / "handle edge cases"
- "Write tests for the above" (without actual test code)
- "Similar to Task N" (repeat the code — the engineer may be reading tasks out of order)
- Steps that describe what to do without showing how (code blocks required for code steps)
- References to types, functions, or methods not defined in any task

## Remember
- Exact file paths always
- Complete code in every step — if a step changes code, show the code
- Exact commands with expected output
- DRY, YAGNI, TDD, frequent commits

## Self-Review

After writing the complete plan, look at the spec with fresh eyes and check the plan against it. This is a checklist you run yourself — not a subagent dispatch.

**1. Spec coverage:** Skim each section/requirement in the spec. Can you point to a task that implements it? List any gaps.

**2. Placeholder scan:** Search your plan for red flags — any of the patterns from the "No Placeholders" section above. Fix them.

**3. Type consistency:** Do the types, method signatures, and property names you used in later tasks match what you defined in earlier tasks? A function called `clearLayers()` in Task 3 but `clearFullLayers()` in Task 7 is a bug.

If you find issues, fix them inline. No need to re-review — just fix and move on. If you find a spec requirement with no task, add the task.

## Execution Handoff

After saving the plan, offer execution choice:

**"Plan complete and saved to `docs/superpowers/plans/<filename>.md`. Two execution options:**

**1. Subagent-Driven (recommended)** - I dispatch a fresh subagent per task, review between tasks, fast iteration

**2. Inline Execution** - Execute tasks in this session using executing-plans, batch execution with checkpoints

**Which approach?"**

**If Subagent-Driven chosen:**
- **REQUIRED SUB-SKILL:** Use superpowers:subagent-driven-development
- Fresh subagent per task + two-stage review

**If Inline Execution chosen:**
- **REQUIRED SUB-SKILL:** Use superpowers:executing-plans
- Batch execution with checkpoints for review


I'm using the writing-plans skill to create the implementation plan.

Let me do a quick check on the pxl-components package name before writing the plan.


Bash

cat /Users/steve.briggs/projects/pxl-components/projects/pxl-components/package.json 2>/dev/null | python3 -c "import json,sys; d=json.load(sys.stdin); print(d.get('name'), d.get('version'))" && cat /Users/steve.briggs/projects/pxl-components/package.json | python3 -c "import json,sys; d=json.load(sys.stdin); print('name:', d.get('name'), 'version:', d.get('version'))"

Get pxl-components package name and version


@immuta/pxl-components 21.0.5
name: pxl-components version: 21.0.5


cat /Users/steve.briggs/projects/pxl-components/.npmrc 2>/dev/null && echo "---" && node --version && go version 2>/dev/null

Check npm registry config and tool versions


@immuta:registry=https://pkg-hub.infrastructure.immuta.io/repository/npm-hosted/
@awesome.me:registry=https://pkg-hub.infrastructure.immuta.io/repository/fa-pro-npm-proxy/
legacy-peer-deps=true
---
v24.5.0
go version go1.23.12 darwin/arm64


npx @angular/cli@21 version 2>/dev/null | head -5 || echo "no ng"

Check Angular CLI availability


     _                      _                 ____ _     ___
    / \   _ __   __ _ _   _| | __ _ _ __     / ___| |   |_ _|
   / △ \ | '_ \ / _` | | | | |/ _` | '__|   | |   | |    | |
  / ___ \| | | | (_| | |_| | | (_| | |      | |___| |___ | |


which ng && ng version 2>/dev/null | head -3 || echo "no global ng"

Check for global Angular CLI


/opt/homebrew/bin/ng

     _                      _                 ____ _     ___
    / \   _ __   __ _ _   _| | __ _ _ __     / ___| |   |_ _|


ng version 2>/dev/null | grep "Angular CLI"

Get Angular CLI version


Angular CLI       : 21.2.0


Got everything I need. Now writing the plan.


Create plans directory


(Bash completed with no output)


API Error: Claude's response exceeded the 32000 output token maximum. To configure this behavior, set the CLAUDE_CODE_MAX_OUTPUT_TOKENS environment variable.


API Error: Claude's response exceeded the 32000 output token maximum. To configure this behavior, set the
     CLAUDE_CODE_MAX_OUTPUT_TOKENS environment variable.


Explore project context

Check pxl-components library, data files, and any existing project structure

Offer visual companion

Offer the visual browser companion for UI mockups

Ask clarifying questions

One at a time, understand purpose, constraints, and success criteria

Propose 2-3 approaches

Present trade-offs and a recommendation

Present design

In sections scaled to complexity, get user approval after each section

Write design doc

Save to docs/superpowers/specs/YYYY-MM-DD-heapoverflow-design.md and commit

Spec self-review

Check for placeholders, contradictions, ambiguity, scope issues

User reviews written spec

Ask user to review spec file before proceeding

Transition to implementation

Invoke writing-plans skill to create implementation plan


The plan is too large to output in a single response. I'll write it directly to the file in two parts — backend first, then frontend — and stitch them together.


Writing backend plan directly to file.


<command-name>/config</command-name>
            <command-message>config</command-message>
            <command-args></command-args>


<local-command-stdout>Config dialog dismissed</local-command-stdout>


<command-message>update-config</command-message>
<command-name>/update-config</command-name>