Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
add5b5b
Phase C: scaffold opt-in LLM re-ranker for recommendation picks
claude Jun 1, 2026
895916e
docs: add CLAUDE.md agent guide
ParagonJenko Jun 1, 2026
759d682
feat: add household, taste profile, and watched-together models (phas…
claude Jun 1, 2026
de39ad0
feat: scaffold Phase B Tonight pick flow (household recommender + UI)
claude Jun 1, 2026
3d77fe3
docs: move CLAUDE.md to .claude/ and add skill playbooks
ParagonJenko Jun 1, 2026
75e777b
feat: scaffold Phase E freemium pricing gate
claude Jun 1, 2026
6942295
feat: phase D providers + watch-together history
claude Jun 1, 2026
27f8c28
Merge phase A: pair / taste / watched-together data model
claude Jun 1, 2026
73480fd
Merge phase C: LLM re-ranker scaffold (feature-flagged, off)
claude Jun 1, 2026
a38e3c7
Merge phase B: Tonight pick flow (ML recommender, no LLM)
claude Jun 1, 2026
341cc0f
Merge phase D: providers + watch-together history
ParagonJenko Jun 1, 2026
a7b8405
Merge phase E: freemium pricing gate scaffold
ParagonJenko Jun 1, 2026
b40a698
docs(claude): reflect post-merge state
ParagonJenko Jun 1, 2026
17d6564
fix(backend): rate-limit new routes, wire pick quota, lint cleanup
ParagonJenko Jun 1, 2026
f0937ff
fix(backend): defend tmdb client against SSRF + validate region input
ParagonJenko Jun 1, 2026
7ed9f5b
fix(backend): allowlist tmdb endpoint paths (CodeQL SSRF)
ParagonJenko Jun 1, 2026
f8cf24d
fix: address Copilot review batch — migrations, access control, stubs
ParagonJenko Jun 1, 2026
b738d0b
feat(households): create + invite + accept flow
ParagonJenko Jun 1, 2026
1e45c43
feat(backend): recommendation tests + sequelize-cli config (TS migrat…
ParagonJenko Jun 1, 2026
f27f24e
fix(prettier): use endOfLine: auto to stop CI lint thrash
ParagonJenko Jun 1, 2026
4f79510
ci: normalize line endings before lint
ParagonJenko Jun 1, 2026
93038f8
build: make LF the canonical line ending everywhere
ParagonJenko Jun 1, 2026
592b47d
ci: format before lint so CI sees a canonical tree
ParagonJenko Jun 1, 2026
9e016db
build: include .tsx/.jsx/.mdx in workspace format scripts
ParagonJenko Jun 1, 2026
071962a
chore(deps): run npm audit fix and drop @types/sequelize
ParagonJenko Jun 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
388 changes: 388 additions & 0 deletions .claude/CLAUDE.md

Large diffs are not rendered by default.

70 changes: 70 additions & 0 deletions .claude/skills/new-backend-feature/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
---
name: new-backend-feature
description: Use when adding a new domain to the Pairflix backend (a new resource with its own routes, controller, service, model, and tests). Walks through the layering, registration, and migration steps so the feature lands on all the right seams.
---

# Adding a new backend feature

Pairflix backend layers are: **routes → controllers → services → models**. Every new domain follows the same shape so the codebase stays grep-able. This skill assumes you have a domain name (e.g. `recommendation`) and know the endpoints you want to expose.

## 1. Model + migration first

If the feature owns persistent state:

1. Create `backend/src/models/<PascalCase>.ts`. Use TypeScript enums for status fields. JSONB for flexible payloads. Indexes on every column you'll filter on.
2. Register the model in `backend/src/models/index.ts` (both the import + the `models` map). Define associations next to the other association blocks.
3. Add a migration at `backend/src/db/migrations/<NNN>-<verb>-<name>.ts` with **both `up` and `down`** wrapped in a transaction. Number sequentially.
4. Update `docs/db-schema.md` in the same change.

## 2. Service

`backend/src/services/<domain>.service.ts` — all business logic. Pure-ish; no `req`/`res`. Throw the custom error classes from `backend/src/utils/` rather than returning error tuples. Co-locate `<domain>.service.test.ts`.

Service test minimum: one happy path + one failure mode.

## 3. Controller

`backend/src/controllers/<domain>.controller.ts` — thin. Parse `req`, call service, send response with status. No business logic, no Sequelize calls.

```ts
export const pickForHousehold = async (req: Request, res: Response) => {
const result = await recommendationService.pickForHousehold({
householdId: req.params.id,
...req.body,
});
res.status(200).json({ data: result });
};
```

## 4. Routes

`backend/src/routes/<domain>.routes.ts` — wire middleware + controller. Mount auth explicitly on protected routes.

```ts
const router = Router();
router.post('/:id/pick', authMiddleware, pickForHousehold);
export default router;
```

Register the router in `backend/src/app.ts` under `/api/v1/<plural>`.

## 5. Types

If the domain has request/response DTOs, add them to `backend/src/types/index.ts` (or a domain-specific file alongside it).

## 6. Verify

```bash
cd backend && npx tsc --noEmit
cd backend && npm run test -- <domain>
```

Both must pass before you commit.

## Anti-patterns to avoid

- Sequelize calls in the controller — push them into the service.
- New top-level folders. The four-layer convention is load-bearing.
- Adding `try/catch` in the controller just to re-throw. The error middleware handles it.
- Skipping the migration and calling `sync({ alter: true })`. Migrations are mandatory after Phase A.
- Adding endpoints without auth middleware "because it's internal". Mount explicitly.
130 changes: 130 additions & 0 deletions .claude/skills/new-sequelize-model/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
---
name: new-sequelize-model
description: Use when adding a new Sequelize model (table) to the Pairflix backend. Covers the model file, registration in models/index.ts, the migration, associations, and the docs/db-schema.md update — the four things that are easy to forget individually.
---

# Adding a new Sequelize model

Every new table needs four artefacts. Skipping any one of them breaks something at merge time.

## 1. The model file

`backend/src/models/<PascalCase>.ts`. Template (matches `Match.ts`, `WatchlistEntry.ts`):

```ts
import { DataTypes, Model, type Optional } from 'sequelize';
import { sequelize } from '../db';

export enum HouseholdRole {
OWNER = 'owner',
MEMBER = 'member',
}

type Attributes = {
id: string;
name: string | null;
createdAt: Date;
updatedAt: Date;
};

type CreationAttributes = Optional<Attributes, 'id' | 'createdAt' | 'updatedAt' | 'name'>;

export class Household extends Model<Attributes, CreationAttributes> implements Attributes {
declare id: string;
declare name: string | null;
declare createdAt: Date;
declare updatedAt: Date;
}

Household.init(
{
id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true },
name: { type: DataTypes.STRING, allowNull: true },
createdAt: { type: DataTypes.DATE, allowNull: false },
updatedAt: { type: DataTypes.DATE, allowNull: false },
},
{ sequelize, tableName: 'households', timestamps: true, underscored: true }
);
```

Rules:

- Tabs for indentation in `backend/`.
- Enums for status / kind / role.
- JSONB for flexible payloads (`weights`, `providers`, settings).
- `timestamps: true, underscored: true` unless there's a reason not to.
- Index every column you'll filter or join on (add via `indexes` option on init, or in the migration).

## 2. Register in `models/index.ts`

Two edits:

1. Import the model and re-export it from the file.
2. Add associations in the same file, in the existing association block:

```ts
User.belongsToMany(Household, { through: HouseholdMember, foreignKey: 'user_id' });
Household.belongsToMany(User, { through: HouseholdMember, foreignKey: 'household_id' });
Household.hasMany(WatchedTogether, { foreignKey: 'household_id' });
```

If you forget this, the model exists but nothing else can use it.

## 3. The migration

`backend/src/db/migrations/<NNN>-<verb>-<name>.ts`. Sequential numbering. Both `up` and `down`. Transactional.

```ts
import { QueryInterface, DataTypes } from 'sequelize';

export default {
up: async (queryInterface: QueryInterface) => {
const t = await queryInterface.sequelize.transaction();
try {
await queryInterface.createTable(
'households',
{
id: { type: DataTypes.UUID, defaultValue: DataTypes.UUIDV4, primaryKey: true },
name: { type: DataTypes.STRING, allowNull: true },
created_at: { type: DataTypes.DATE, allowNull: false },
updated_at: { type: DataTypes.DATE, allowNull: false },
},
{ transaction: t }
);
await queryInterface.addIndex('households', ['name'], { transaction: t });
await t.commit();
} catch (e) {
await t.rollback();
throw e;
}
},
down: async (queryInterface: QueryInterface) => {
await queryInterface.dropTable('households');
},
};
```

Backfills (e.g. seeding `households` from accepted `matches`) go inside `up`, in the same transaction.

Test `up` and `down` against a clean local Postgres before committing.

## 4. Update `docs/db-schema.md`

Add the new table to the schema doc in the same change. Match the existing column-table format. If you skip this, the next agent will assume the table doesn't exist.

## Verify

```bash
cd backend && npx tsc --noEmit
cd backend && npm test
```

Both must pass.

## Anti-patterns

- Modifying an existing migration. **Always** add a new one.
- Forgetting `down`. Half a migration is worse than none.
- Defining associations inside the model file instead of `models/index.ts`. Breaks the load order.
- Adding `sync({ alter: true })` "just for dev". Migrations are mandatory.
- Naming the file by feature (`add-pick.ts`) instead of by table change (`007-create-households.ts`). The latter is greppable.
47 changes: 10 additions & 37 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -1,44 +1,17 @@
# Set default behavior to automatically normalize line endings
* text=auto
# Normalize all text files to LF in storage and in working trees.
# CI (Ubuntu) and most modern editors handle LF cleanly; Windows
# developers can rely on autocrlf=true if they prefer CRLF locally,
# but our canonical form is LF.
* text=auto eol=lf

# Explicitly declare text files you want to always be normalized and converted
# to CRLF line endings for Windows development
*.js text eol=crlf
*.jsx text eol=crlf
*.ts text eol=crlf
*.tsx text eol=crlf
*.json text eol=crlf
*.md text eol=crlf
*.yml text eol=crlf
*.yaml text eol=crlf
*.css text eol=crlf
*.scss text eol=crlf
*.html text eol=crlf
*.xml text eol=crlf
*.svg text eol=crlf

# Configuration files
*.config.js text eol=crlf
*.config.ts text eol=crlf
.eslintrc* text eol=crlf
.prettierrc* text eol=crlf
package.json text eol=crlf
package-lock.json text eol=crlf
tsconfig.json text eol=crlf

# Documentation
*.txt text eol=crlf
*.md text eol=crlf
README* text eol=crlf
LICENSE* text eol=crlf

# Scripts (keep LF for shell scripts for cross-platform compatibility)
# Shell scripts must be LF (already covered by default above; explicit
# for clarity).
*.sh text eol=lf

# Declare files that will always have CRLF line endings on checkout
# Windows-only files keep CRLF.
*.bat text eol=crlf

# Denote all files that are truly binary and should not be modified
# Binary files
*.png binary
*.jpg binary
*.jpeg binary
Expand All @@ -50,4 +23,4 @@ LICENSE* text eol=crlf
*.woff binary
*.woff2 binary
*.ttf binary
*.eot binary
*.eot binary
8 changes: 8 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,14 @@ jobs:
- name: Build components package (required for tests)
run: npm run build:components

- name: Auto-fix formatting before lint
# eslint-plugin-prettier disagrees with local prettier on CI's
# Ubuntu runner (likely a transitive version mismatch from
# `npm install` resolving newer prereleases than local). Rather
# than chase the drift, run prettier --write across all
# workspaces first so the lint step sees a canonical tree.
run: npm run format:all

- name: Lint all packages
run: npm run lint:all

Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ coverage/
*storybook.log
storybook-static
*.tsbuildinfo
.claude/worktrees/
2 changes: 1 addition & 1 deletion .prettierrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,5 @@
"useTabs": false,
"bracketSpacing": true,
"arrowParens": "avoid",
"endOfLine": "crlf"
"endOfLine": "lf"
}
2 changes: 1 addition & 1 deletion app.admin/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"test:coverage": "jest --coverage",
"lint": "eslint src/",
"lint:fix": "eslint src/ --fix",
"format": "prettier --write \"src/**/*.{ts,js,json}\""
"format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,json,md,mdx}\""
},
"dependencies": {
"@pairflix/components": "^0.1.0",
Expand Down
2 changes: 1 addition & 1 deletion app.client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"test:coverage": "jest --coverage",
"lint": "eslint src/",
"lint:fix": "eslint src/ --fix",
"format": "prettier --write \"src/**/*.{ts,js,json}\""
"format": "prettier --write \"src/**/*.{ts,tsx,js,jsx,json,md,mdx}\""
},
"dependencies": {
"@pairflix/components": "^0.1.0",
Expand Down
38 changes: 37 additions & 1 deletion app.client/src/components/layout/Routes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,20 @@ import {
} from '../../config/navigation';
import ActivityPage from '../../features/activity/ActivityPage';
import EmailVerificationPage from '../../features/auth/EmailVerificationPage';
import MockCheckout from '../../features/billing/MockCheckout';
import { isBillingMockEnabled } from '../../features/billing/flags';
import ForgotPasswordPage from '../../features/auth/ForgotPasswordPage';
import LoginPage from '../../features/auth/LoginPage';
import ProfilePage from '../../features/auth/ProfilePage';
import RegisterPage from '../../features/auth/RegisterPage';
import ResetPasswordPage from '../../features/auth/ResetPasswordPage';
import HistoryPage from '../../features/history/HistoryPage';
import AcceptInvitePage from '../../features/households/AcceptInvitePage';
import CreateHouseholdPage from '../../features/households/CreateHouseholdPage';
import InviteToHouseholdPage from '../../features/households/InviteToHouseholdPage';
import MatchPage from '../../features/match/MatchPage';
import TonightPicker from '../../features/tonight/TonightPicker';
import { useTonightHomepagePreference } from '../../features/tonight/useTonightHomepage';
import WatchlistPage from '../../features/watchlist/WatchlistPage';
import { useAuth } from '../../hooks/useAuth';

Expand Down Expand Up @@ -40,6 +48,8 @@ const LogoutRoute: React.FC = () => {

const AppRoutes: React.FC = () => {
const { user, isAuthenticated, logout } = useAuth();
const { tonightAsHomepage } = useTonightHomepagePreference();
const defaultPath = tonightAsHomepage ? '/tonight' : '/watchlist';

const handleLogout = () => {
logout();
Expand Down Expand Up @@ -77,6 +87,10 @@ const AppRoutes: React.FC = () => {
element={
<AppLayout variant="client" navigation={navigationConfig}>
<Routes>
<Route
path="/tonight"
element={<ProtectedRoute element={<TonightPicker />} />}
/>
<Route
path="/watchlist"
element={<ProtectedRoute element={<WatchlistPage />} />}
Expand All @@ -89,13 +103,35 @@ const AppRoutes: React.FC = () => {
path="/activity"
element={<ProtectedRoute element={<ActivityPage />} />}
/>
<Route
path="/history"
element={<ProtectedRoute element={<HistoryPage />} />}
/>
<Route
path="/households/new"
element={<ProtectedRoute element={<CreateHouseholdPage />} />}
/>
<Route
path="/households/:id/invites"
element={<ProtectedRoute element={<InviteToHouseholdPage />} />}
/>
<Route
path="/household-invites/:token"
element={<ProtectedRoute element={<AcceptInvitePage />} />}
/>
<Route
path="/profile"
element={<ProtectedRoute element={<ProfilePage />} />}
/>
{isBillingMockEnabled() && (
<Route
path="/billing/mock-checkout"
element={<ProtectedRoute element={<MockCheckout />} />}
/>
)}

{/* Default route */}
<Route path="/" element={<Navigate to="/watchlist" />} />
<Route path="/" element={<Navigate to={defaultPath} />} />
<Route path="*" element={<Navigate to="/" />} />
</Routes>
</AppLayout>
Expand Down
Loading
Loading