Commit45ac593
fix(db): one Drizzle definition per table — five tables were declared twice
fix(db): one Drizzle definition per table — five tables were declared twice src/db/schema-extensions.ts declared its own pgTable for organizations, org_members, teams, team_members, team_repos, branch_protection and notifications, alongside src/db/schema.ts. drizzle.config.ts includes only schema.ts, so those definitions were never migrated: several described tables or columns the database has never had. And because each consumer typechecked against whichever definition it imported, tsc could not see any of it. Unifying the definitions made the compiler surface four latent bugs at once, every one a silent runtime failure: - POST /orgs/new omitted `slug` and `created_by_id` (both NOT NULL) while writing display_name/website, which do not exist. Org creation has never worked. - POST /orgs/:org/teams/new omitted `teams.slug` (NOT NULL, and half of the unique (org_id, slug) index). Team creation has never worked. - `team_repos` had NO migration at all — the table does not exist — yet orgs.tsx selects and joins it to render a team's repository list. - lib/notify.ts inserted `type` but not `kind` (NOT NULL), so EVERY in-app notification insert violated the constraint and was swallowed by its own try/catch. Plus a user-visible one found on the way: the notifications table carries two parallel read-state representations — `is_read` (migration 0089) and `read_at` (original). Marking a notification read wrote only `is_read`, while the nav badge (lib/unread.ts) and the daily digest (lib/smart-digest.ts) both count `read_at IS NULL`. Read notifications therefore never stopped counting as unread. Mark-read now writes both. Migrations 0113 and 0114 are additive and idempotent (ADD COLUMN IF NOT EXISTS / CREATE TABLE IF NOT EXISTS, no drops). Columns are ADDED rather than the UI trimmed, because the create forms genuinely collect a website, a display name and a team permission — dropping them would discard user input to match an accident of history. The regression test fails the build for ANY table declared in more than one schema file, so this class cannot recur. Consolidating notifications onto a single read-state representation needs a data backfill and is deliberately left as a separate change — the parallel columns are documented in schema.ts rather than quietly tidied. Also adds the authz-matrix HARD gate to scripts/production-readiness.mjs: it enumerates every repo-scoped route from source at run time and probes each against a private and a public repo, checking BOTH directions (leaks and over-blocking). A hand-maintained route list would drift exactly like the per-route privacy checks that leaked 24 subpages. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
9 files changed+492−13945ac59305e2026c3853f1f30a5b765ee513155e1
9 changed files+492−139
Addeddrizzle/0113_orgs_reconcile.sql+31−0View fileUnifiedSplit
@@ -0,0 +1,31 @@
1-- Reconcile the `organizations` table with the code that writes to it.
2--
3-- Two Drizzle definitions both mapped to this one physical table:
4--
5-- src/db/schema.ts slug NOT NULL, created_by_id NOT NULL,
6-- billing_email — matches migration 0003, i.e.
7-- matches the real database.
8-- src/db/schema-extensions.ts display_name, website, location, is_verified,
9-- and NEITHER not-null column — describes a
10-- table this repo has never created.
11--
12-- drizzle.config.ts includes only schema.ts and deploys run db:migrate (never
13-- db:push), so schema-extensions has never been part of the migration
14-- pipeline. src/routes/orgs.tsx imports from schema-extensions, so
15-- POST /orgs/new inserted display_name/website (columns that do not exist)
16-- while omitting slug/created_by_id (NOT NULL, no default). Organization
17-- creation has therefore never succeeded — it fails on the first missing
18-- column every time.
19--
20-- Adding the columns rather than deleting the fields: the create form collects
21-- a display name and a website, and dropping them would silently discard what
22-- the user typed. All four are nullable (is_verified defaulted) so this is
23-- additive and safe to run against a populated table.
24
25ALTER TABLE "organizations" ADD COLUMN IF NOT EXISTS "display_name" text;
26--> statement-breakpoint
27ALTER TABLE "organizations" ADD COLUMN IF NOT EXISTS "website" text;
28--> statement-breakpoint
29ALTER TABLE "organizations" ADD COLUMN IF NOT EXISTS "location" text;
30--> statement-breakpoint
31ALTER TABLE "organizations" ADD COLUMN IF NOT EXISTS "is_verified" boolean DEFAULT false NOT NULL;
Addeddrizzle/0114_teams_reconcile.sql+37−0View fileUnifiedSplit
@@ -0,0 +1,37 @@
1-- Reconcile the teams feature with the code that uses it.
2--
3-- Same class as 0113. src/db/schema-extensions.ts declared its own `teams`,
4-- `team_members` and `team_repos` alongside src/db/schema.ts, and only
5-- schema.ts is in drizzle.config.ts. Comparing the extensions definitions
6-- against migration 0003 (the real database):
7--
8-- teams extensions adds `permission`, omits the NOT NULL `slug`.
9-- src/routes/orgs.tsx inserts without slug and renders
10-- team.permission — so team creation fails on the NOT NULL
11-- slug, and the badge reads a column that does not exist.
12-- team_members extensions omits `role`; harmless, it has a default.
13-- team_repos HAS NO MIGRATION AT ALL. The table has never existed, yet
14-- orgs.tsx selects and joins it to render a team's repository
15-- list, so that panel could only ever have errored.
16--
17-- Adding rather than removing, for the same reason as 0113: the team create
18-- form collects a permission level and the team page is built to list repos.
19-- Deleting those would throw away working UI to match an accident of history.
20--
21-- `permission` is defaulted so existing rows remain valid.
22
23ALTER TABLE "teams" ADD COLUMN IF NOT EXISTS "permission" text DEFAULT 'read' NOT NULL;
24--> statement-breakpoint
25
26CREATE TABLE IF NOT EXISTS "team_repos" (
27 "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
28 "team_id" uuid NOT NULL,
29 "repository_id" uuid NOT NULL,
30 "permission" text DEFAULT 'read' NOT NULL,
31 "created_at" timestamp DEFAULT now() NOT NULL,
32 CONSTRAINT "team_repos_team_fk" FOREIGN KEY ("team_id") REFERENCES "teams"("id") ON DELETE cascade,
33 CONSTRAINT "team_repos_repo_fk" FOREIGN KEY ("repository_id") REFERENCES "repositories"("id") ON DELETE cascade
34);
35--> statement-breakpoint
36
37CREATE UNIQUE INDEX IF NOT EXISTS "team_repos_unique" ON "team_repos" ("team_id", "repository_id");
Modifiedscripts/production-readiness.mjs+122−1View fileUnifiedSplit
@@ -33,7 +33,8 @@
3333 */
3434
3535import { chromium } from '@playwright/test';
36import { writeFileSync } from 'fs';
36import { writeFileSync, readdirSync, readFileSync, statSync } from 'fs';
37import { join } from 'path';
3738
3839const argv = process.argv.slice(2);
3940const arg = (name, fallback = null) => {
@@ -44,8 +45,61 @@ const arg = (name, fallback = null) => {
4445const BASE = (arg('base', process.env.SMOKE_HOST || 'https://gluecron.com')).replace(/\/$/, '');
4546const EXPECT_SHA = arg('expect-sha', process.env.EXPECT_SHA);
4647const PRIVATE_REPO = arg('private-repo', process.env.PRIVATE_REPO); // "owner/name"
48const PUBLIC_REPO = arg('public-repo', process.env.PUBLIC_REPO); // "owner/name"
49const OWNER_PAT = process.env.GLUECRON_PAT; // owner of PRIVATE_REPO
50const NONMEMBER_PAT = process.env.NONMEMBER_PAT; // any account with no access
4751const JSON_OUT = arg('json');
4852
53/**
54 * Every repo-scoped route the app registers, read from source at run time.
55 *
56 * Deliberately NOT a hand-maintained list. The 24 private-repo subpages that
57 * were leaking on 2026-07-28 leaked precisely because each route owned its own
58 * privacy check and nobody kept a central list in step. A gate with a literal
59 * array would drift the same way — a route added tomorrow would be untested
60 * and silently uncovered. Parsing the router registrations means new routes are
61 * covered the moment they exist.
62 */
63function repoScopedGetRoutes(root = 'src/routes') {
64 const files = [];
65 const walk = (d) => {
66 for (const e of readdirSync(d)) {
67 const p = join(d, e);
68 if (statSync(p).isDirectory()) walk(p);
69 else if (e.endsWith('.ts') || e.endsWith('.tsx')) files.push(p);
70 }
71 };
72 try { walk(root); } catch { return []; }
73
74 const re = /\.(get)\(\s*(["'`])(\/:owner\/:repo[^"'`\n]*)\2/g;
75 const paths = new Set();
76 for (const f of files) {
77 const src = readFileSync(f, 'utf8');
78 let m;
79 re.lastIndex = 0;
80 while ((m = re.exec(src))) {
81 let p = m[3];
82 // Skip the git Smart HTTP surface: `:repo.git` has its own auth and
83 // returns 401, not a page.
84 if (p.includes('.git')) continue;
85 // Drop Hono regex constraints: "/:ref{.+$}" -> "/:ref".
86 p = p.replace(/\{[^}]*\}/g, '');
87 // Routes needing a synthetic id we cannot fabricate are still worth
88 // probing — a bogus id should 404, so a 200 is a leak either way.
89 paths.add(p);
90 }
91 }
92 return [...paths].sort();
93}
94
95/** Fill :owner/:repo, and give any remaining params a placeholder. */
96function materialize(routePath, owner, repo) {
97 return routePath
98 .replace('/:owner/:repo', `/${owner}/${repo}`)
99 .replace(/\/:[A-Za-z_]+\??/g, '/_probe')
100 .replace(/\/\*$/, '/_probe');
101}
102
49103// Pages sampled for render/overflow gates. Keep this list small and
50104// representative — it runs on every deploy.
51105const PAGES = [
@@ -116,6 +170,73 @@ async function main() {
116170 add('privacy', true, true, 'SKIPPED — pass --private-repo owner/name to enable (strongly recommended)');
117171 }
118172
173 // ── HARD: authorization matrix over EVERY repo-scoped route ───────────
174 //
175 // The gate that would have caught most of 2026-07-27/28. On that date the
176 // three narrow `privacy` surfaces above all passed while 24 other
177 // repo-scoped subpages served a private repo to anonymous visitors, because
178 // nothing enumerated the full surface.
179 //
180 // Checks BOTH directions on purpose. "Private is blocked" alone would pass
181 // trivially if a change broke every repo page, so the public repo must keep
182 // rendering. A one-directional privacy test is how over-blocking ships.
183 if (PRIVATE_REPO && PUBLIC_REPO) {
184 const [po, pn] = PRIVATE_REPO.split('/');
185 const [uo, un] = PUBLIC_REPO.split('/');
186 const routes = repoScopedGetRoutes();
187 const leaks = [];
188 const overblocked = [];
189 const errors = [];
190
191 const probe = async (path, token) => {
192 const headers = token ? { authorization: `Bearer ${token}` } : {};
193 try {
194 const r = await fetch(`${BASE}${path}`, { headers, redirect: 'manual' });
195 return r.status;
196 } catch {
197 return 0;
198 }
199 };
200
201 for (const route of routes) {
202 // 1. Private repo must not render to an anonymous caller.
203 const anon = await probe(materialize(route, po, pn), null);
204 if (anon === 200) leaks.push(`anon ${route} -> 200`);
205
206 // 2. ...nor to a signed-in account with no access, when one is supplied.
207 if (NONMEMBER_PAT) {
208 const nm = await probe(materialize(route, po, pn), NONMEMBER_PAT);
209 if (nm === 200) leaks.push(`non-member ${route} -> 200`);
210 }
211
212 // 3. The OWNER must always reach their own repo. Probing this
213 // anonymously would be wrong: plenty of repo routes legitimately
214 // require a session (the AI surfaces — /ask, /chat, /claude — all
215 // 302 to login by design). Over-blocking means the owner is shut
216 // out, which is what a too-aggressive privacy gate actually breaks.
217 if (OWNER_PAT) {
218 const own = await probe(materialize(route, uo, un), OWNER_PAT);
219 if (own === 302 || own === 401 || own === 403) {
220 overblocked.push(`owner ${route} -> ${own}`);
221 }
222 // A placeholder id must 404, never crash the handler.
223 if (own >= 500) errors.push(`${route} -> ${own}`);
224 }
225 }
226
227 const detail = [];
228 if (leaks.length) detail.push(`LEAK: ${leaks.slice(0, 8).join(', ')}`);
229 if (overblocked.length) detail.push(`OVER-BLOCKED: ${overblocked.slice(0, 8).join(', ')}`);
230 if (errors.length) detail.push(`5xx: ${errors.slice(0, 8).join(', ')}`);
231 const covered = `${routes.length} repo routes x ${NONMEMBER_PAT ? 3 : 2} identities`;
232 add('authz-matrix', true, leaks.length === 0 && overblocked.length === 0 && errors.length === 0,
233 detail.length ? detail.join(' | ') : `${covered}, no leaks or over-blocking`
234 + (NONMEMBER_PAT ? '' : ' (set NONMEMBER_PAT for the non-member row)'));
235 } else {
236 add('authz-matrix', true, true,
237 'SKIPPED — needs --private-repo AND --public-repo (this is the gate that catches leaks; enable it)');
238 }
239
119240 // ── HARD: auth-only pages must not render anonymously ─────────────────
120241 const rendered = [];
121242 for (const p of AUTH_ONLY) {
Addedsrc/__tests__/schema-single-source.test.ts+153−0View fileUnifiedSplit
@@ -0,0 +1,153 @@
1/**
2 * One Drizzle definition per physical table.
3 *
4 * `organizations` was declared TWICE — in schema.ts (slug + created_by_id
5 * NOT NULL, matching migration 0003 and therefore the real database) and in
6 * schema-extensions.ts (display_name/website/location/is_verified, neither
7 * NOT NULL column). Both mapped to the same physical table with irreconcilable
8 * shapes.
9 *
10 * drizzle.config.ts includes only schema.ts and deploys run `db:migrate`,
11 * never `db:push`, so the schema-extensions version was never migrated: it
12 * described a table that has never existed. routes/orgs.tsx imported it, so
13 * POST /orgs/new wrote columns Postgres does not have while omitting two that
14 * are NOT NULL. Organization creation could never have succeeded, and because
15 * each file typechecked against its own definition, tsc could not see it.
16 *
17 * The duplicate-table check below is the one that matters: it fails the build
18 * for ANY table declared in more than one place, so this class cannot recur.
19 */
20
21import { describe, expect, it } from "bun:test";
22import { readFileSync, readdirSync } from "fs";
23import { join } from "path";
24
25const DB_DIR = "src/db";
26
27/**
28 * Strip comments before scanning. Without this the check matches its own
29 * documentation: these files explain the duplicate-table bug in prose that
30 * necessarily contains `pgTable("organizations"`, and a naive scan then
31 * reports the very thing the comment says was fixed.
32 */
33function code(src: string): string {
34 return src
35 .replace(/\/\*[\s\S]*?\*\//g, "")
36 .replace(/^\s*\/\/.*$/gm, "");
37}
38
39function schemaFiles(): string[] {
40 return readdirSync(DB_DIR)
41 .filter((f) => f.endsWith(".ts") && f.includes("schema"))
42 .map((f) => join(DB_DIR, f));
43}
44
45describe("no physical table is declared twice", () => {
46 it("every pgTable name is unique across all schema files", () => {
47 const seen = new Map<string, string[]>();
48 for (const f of schemaFiles()) {
49 const src = code(readFileSync(f, "utf8"));
50 for (const m of src.matchAll(/pgTable\(\s*["'`]([^"'`]+)["'`]/g)) {
51 const table = m[1];
52 if (!seen.has(table)) seen.set(table, []);
53 seen.get(table)!.push(f.replace(/\\/g, "/"));
54 }
55 }
56 const dupes = [...seen.entries()]
57 .filter(([, files]) => files.length > 1)
58 .map(([t, files]) => `${t} declared in ${files.join(" AND ")}`);
59 expect(dupes).toEqual([]);
60 });
61
62 it("schema-extensions re-exports the org/team tables rather than redeclaring them", () => {
63 const src = code(readFileSync("src/db/schema-extensions.ts", "utf8"));
64 // Matched loosely on purpose: asserting an exact import string breaks the
65 // moment the list wraps across lines, which says nothing about the bug.
66 expect(src).toMatch(/from\s+["']\.\/schema["']/);
67 for (const t of ["organizations", "teams", "team_members", "team_repos", "org_members", "branch_protection"]) {
68 expect(src).not.toMatch(new RegExp(`pgTable\\(\\s*["'\`]${t}["'\`]`));
69 }
70 });
71});
72
73describe("the organizations table matches its migration", () => {
74 const schema = readFileSync("src/db/schema.ts", "utf8");
75 const table = schema.slice(
76 schema.indexOf('export const organizations = pgTable("organizations"'),
77 schema.indexOf('export const orgMembers')
78 );
79
80 it("keeps the NOT NULL columns migration 0003 created", () => {
81 expect(table).toContain('slug: text("slug").notNull()');
82 expect(table).toContain('createdById: uuid("created_by_id")');
83 expect(table).toContain(".notNull()");
84 });
85
86 it("carries the columns migration 0113 added", () => {
87 // Added rather than dropped, so the create form's display name and
88 // website are not silently discarded.
89 for (const col of ["display_name", "website", "location", "is_verified"]) {
90 expect(table).toContain(col);
91 }
92 });
93
94 it("has a migration that adds those columns", () => {
95 const sql = readFileSync("drizzle/0113_orgs_reconcile.sql", "utf8");
96 for (const col of ["display_name", "website", "location", "is_verified"]) {
97 expect(sql).toContain(`ADD COLUMN IF NOT EXISTS "${col}"`);
98 }
99 // Additive and idempotent — safe against a populated table and a re-run.
100 expect(sql).not.toMatch(/DROP COLUMN|DROP TABLE/i);
101 });
102});
103
104describe("POST /orgs/new supplies every required column", () => {
105 const src = readFileSync("src/routes/orgs.tsx", "utf8");
106 const insert = src.slice(
107 src.indexOf(".insert(organizations)"),
108 src.indexOf(".insert(organizations)") + 700
109 );
110
111 it("provides slug and createdById", () => {
112 expect(insert).toContain("slug:");
113 expect(insert).toContain("createdById:");
114 });
115
116 it("derives the slug from the validated name", () => {
117 // `name` is already constrained to /^[a-zA-Z0-9._-]+$/ and collision-
118 // checked against both users and orgs, so lowercasing it is a safe slug.
119 expect(insert).toContain("slug: name.toLowerCase()");
120 });
121});
122
123describe("teams feature matches the database", () => {
124 const schema = readFileSync("src/db/schema.ts", "utf8");
125
126 it("teams carries the permission column the UI renders", () => {
127 // orgs.tsx renders `team.permission` and the create form collects it, but
128 // the column existed only in the rival definition and was never migrated.
129 // Anchored with a regex, not a literal newline — these files are CRLF.
130 const t = schema.slice(schema.search(/pgTable\(\s*"teams"/), schema.indexOf("export const teamMembers"));
131 expect(t).toContain('permission: text("permission")');
132 });
133
134 it("team_repos is declared in schema.ts so it gets a migration", () => {
135 // It previously lived ONLY in schema-extensions, which drizzle.config.ts
136 // does not include — so the table never existed, while orgs.tsx selected
137 // and joined it to render a team's repo list.
138 expect(schema).toMatch(/pgTable\(\s*"team_repos"/);
139 });
140
141 it("migration 0114 creates team_repos and adds teams.permission", () => {
142 const sql = readFileSync("drizzle/0114_teams_reconcile.sql", "utf8");
143 expect(sql).toContain('CREATE TABLE IF NOT EXISTS "team_repos"');
144 expect(sql).toContain('ALTER TABLE "teams" ADD COLUMN IF NOT EXISTS "permission"');
145 expect(sql).not.toMatch(/DROP COLUMN|DROP TABLE/i);
146 });
147
148 it("team creation supplies the NOT NULL slug", () => {
149 const orgs = readFileSync("src/routes/orgs.tsx", "utf8");
150 const insert = orgs.slice(orgs.indexOf(".insert(teams)"), orgs.indexOf(".insert(teams)") + 400);
151 expect(insert).toContain("slug,");
152 });
153});
Modifiedsrc/db/schema-extensions.ts+35−134View fileUnifiedSplit
@@ -21,35 +21,6 @@ import { users, repositories } from "./schema";
2121
2222// ─── Branch Protection Rules ────────────────────────────────────────────────
2323
24export const branchProtection = pgTable(
25 "branch_protection",
26 {
27 id: uuid("id").primaryKey().defaultRandom(),
28 repositoryId: uuid("repository_id")
29 .notNull()
30 .references(() => repositories.id, { onDelete: "cascade" }),
31 pattern: text("pattern").notNull(), // e.g. "main", "release/*"
32 requireStatusChecks: boolean("require_status_checks").default(false).notNull(),
33 requiredChecks: text("required_checks").default(""), // comma-separated check names
34 requireReviews: boolean("require_reviews").default(false).notNull(),
35 requiredReviewCount: integer("required_review_count").default(1).notNull(),
36 requireUpToDate: boolean("require_up_to_date").default(false).notNull(),
37 dismissStaleReviews: boolean("dismiss_stale_reviews").default(false).notNull(),
38 restrictPush: boolean("restrict_push").default(false).notNull(),
39 allowForcePush: boolean("allow_force_push").default(false).notNull(),
40 allowDeletion: boolean("allow_deletion").default(false).notNull(),
41 isActive: boolean("is_active").default(true).notNull(),
42 createdAt: timestamp("created_at").defaultNow().notNull(),
43 updatedAt: timestamp("updated_at").defaultNow().notNull(),
44 },
45 (table) => [
46 index("branch_protection_repo").on(table.repositoryId),
47 uniqueIndex("branch_protection_repo_pattern").on(
48 table.repositoryId,
49 table.pattern
50 ),
51 ]
52);
5324
5425// ─── Status Checks (CI Integration) ────────────────────────────────────────
5526
@@ -77,115 +48,45 @@ export const statusChecks = pgTable(
7748
7849// ─── Notifications ──────────────────────────────────────────────────────────
7950
80export const notifications = pgTable(
81 "notifications",
82 {
83 id: uuid("id").primaryKey().defaultRandom(),
84 userId: uuid("user_id")
85 .notNull()
86 .references(() => users.id, { onDelete: "cascade" }),
87 type: text("type").notNull(), // issue_comment, pr_review, mention, star, ci_status
88 title: text("title").notNull(),
89 body: text("body"),
90 url: text("url"), // link to the relevant page
91 repositoryId: uuid("repository_id").references(() => repositories.id, {
92 onDelete: "cascade",
93 }),
94 actorId: uuid("actor_id").references(() => users.id),
95 isRead: boolean("is_read").default(false).notNull(),
96 createdAt: timestamp("created_at").defaultNow().notNull(),
97 },
98 (table) => [
99 index("notifications_user_read").on(table.userId, table.isRead),
100 index("notifications_user_created").on(table.userId, table.createdAt),
101 ]
102);
10351
10452// ─── Organizations ──────────────────────────────────────────────────────────
10553
106export const organizations = pgTable("organizations", {
107 id: uuid("id").primaryKey().defaultRandom(),
108 name: text("name").notNull().unique(),
109 displayName: text("display_name"),
110 description: text("description"),
111 avatarUrl: text("avatar_url"),
112 website: text("website"),
113 location: text("location"),
114 isVerified: boolean("is_verified").default(false).notNull(),
115 createdAt: timestamp("created_at").defaultNow().notNull(),
116 updatedAt: timestamp("updated_at").defaultNow().notNull(),
117});
118
119export const orgMembers = pgTable(
120 "org_members",
121 {
122 id: uuid("id").primaryKey().defaultRandom(),
123 orgId: uuid("org_id")
124 .notNull()
125 .references(() => organizations.id, { onDelete: "cascade" }),
126 userId: uuid("user_id")
127 .notNull()
128 .references(() => users.id, { onDelete: "cascade" }),
129 role: text("role").notNull().default("member"), // owner, admin, member
130 createdAt: timestamp("created_at").defaultNow().notNull(),
131 },
132 (table) => [
133 uniqueIndex("org_members_unique").on(table.orgId, table.userId),
134 index("org_members_user").on(table.userId),
135 ]
136);
137
138export const teams = pgTable(
139 "teams",
140 {
141 id: uuid("id").primaryKey().defaultRandom(),
142 orgId: uuid("org_id")
143 .notNull()
144 .references(() => organizations.id, { onDelete: "cascade" }),
145 name: text("name").notNull(),
146 description: text("description"),
147 permission: text("permission").notNull().default("read"), // read, write, admin
148 createdAt: timestamp("created_at").defaultNow().notNull(),
149 },
150 (table) => [
151 uniqueIndex("teams_org_name").on(table.orgId, table.name),
152 ]
153);
154
155export const teamMembers = pgTable(
156 "team_members",
157 {
158 id: uuid("id").primaryKey().defaultRandom(),
159 teamId: uuid("team_id")
160 .notNull()
161 .references(() => teams.id, { onDelete: "cascade" }),
162 userId: uuid("user_id")
163 .notNull()
164 .references(() => users.id, { onDelete: "cascade" }),
165 createdAt: timestamp("created_at").defaultNow().notNull(),
166 },
167 (table) => [
168 uniqueIndex("team_members_unique").on(table.teamId, table.userId),
169 ]
170);
54/**
55 * Organizations lives in schema.ts — re-exported here so the historical
56 * import path keeps working.
57 *
58 * This file used to declare its OWN pgTable("organizations") with a different
59 * column set: display_name/website/location/is_verified but no `slug` and no
60 * `created_by_id`. Two Drizzle objects, one physical table, irreconcilable
61 * shapes. drizzle.config.ts only includes schema.ts and deploys run
62 * db:migrate (never db:push), so this definition was never migrated — it
63 * described a table that has never existed. Every insert through it wrote
64 * columns Postgres does not have while omitting two NOT NULL columns, which
65 * is why POST /orgs/new could never succeed.
66 *
67 * Migration 0113 adds the four extra columns to the real table so nothing the
68 * create form collects is discarded. Keeping ONE definition is what stops the
69 * two drifting apart again.
70 */
71import {
72 organizations,
73 orgMembers,
74 teams,
75 teamMembers,
76 teamRepos,
77 branchProtection,
78 notifications,
79} from "./schema";
80export {
81 organizations,
82 orgMembers,
83 teams,
84 teamMembers,
85 teamRepos,
86 branchProtection,
87 notifications,
88};
17189
172export const teamRepos = pgTable(
173 "team_repos",
174 {
175 id: uuid("id").primaryKey().defaultRandom(),
176 teamId: uuid("team_id")
177 .notNull()
178 .references(() => teams.id, { onDelete: "cascade" }),
179 repositoryId: uuid("repository_id")
180 .notNull()
181 .references(() => repositories.id, { onDelete: "cascade" }),
182 permission: text("permission").notNull().default("read"), // read, write, admin
183 createdAt: timestamp("created_at").defaultNow().notNull(),
184 },
185 (table) => [
186 uniqueIndex("team_repos_unique").on(table.teamId, table.repositoryId),
187 ]
188);
18990
19091// ─── Type Exports ───────────────────────────────────────────────────────────
19192
Modifiedsrc/db/schema.ts+60−0View fileUnifiedSplit
@@ -369,6 +369,24 @@ export const gateRuns = pgTable(
369369
370370/**
371371 * In-app notifications. Powered by the activity feed + explicit mentions.
372 *
373 * This table carries TWO parallel representations of the same facts, because
374 * migration 0089 added a second set alongside the original rather than
375 * migrating the data:
376 *
377 * kind / read_at original. Read by lib/unread.ts (nav badge) and
378 * lib/smart-digest.ts (daily digest).
379 * type / is_read added by 0089. Read and written by routes/notifications
380 * .tsx and routes/inbox.tsx.
381 *
382 * `actor_id` likewise came from 0089. Both sets are real columns; the danger
383 * is writing one and reading the other, which is exactly what happened —
384 * marking a notification read only ever set `is_read`, so the badge and digest
385 * (reading `read_at IS NULL`) kept counting it. Mark-read now writes both.
386 *
387 * Consolidating onto one representation needs a data backfill and is a
388 * separate change; declaring the full real shape here at least means the
389 * typechecker can see every column that exists.
372390 */
373391export const notifications = pgTable(
374392 "notifications",
@@ -381,10 +399,13 @@ export const notifications = pgTable(
381399 onDelete: "cascade",
382400 }),
383401 kind: text("kind").notNull(), // mention, review_requested, pr_merged, pr_closed, gate_failed, gate_repaired, ai_review, assigned, security_alert, deploy_success, deploy_failed
402 type: text("type"), // 0089 — parallel to `kind`
384403 title: text("title").notNull(),
385404 body: text("body"),
386405 url: text("url"), // link to the relevant page
406 actorId: uuid("actor_id").references(() => users.id, { onDelete: "set null" }), // 0089
387407 readAt: timestamp("read_at"),
408 isRead: boolean("is_read").default(false).notNull(), // 0089 — parallel to `read_at`
388409 createdAt: timestamp("created_at").defaultNow().notNull(),
389410 },
390411 (table) => [
@@ -1092,12 +1113,24 @@ export type SavedReply = typeof savedReplies.$inferSelect;
10921113 * checked at create time in the route handler (no DB-level cross-table
10931114 * uniqueness in Postgres).
10941115 */
1116/**
1117 * Organizations — the single definition. `src/db/schema-extensions.ts`
1118 * re-exports this; it previously declared a SECOND, incompatible table object
1119 * for the same physical table (display_name/website/location/is_verified, no
1120 * slug, no created_by_id). Only schema.ts is in drizzle.config.ts, so that
1121 * other definition was never migrated and every write through it failed.
1122 * Columns below marked "added in 0113" reconcile the two.
1123 */
10951124export const organizations = pgTable("organizations", {
10961125 id: uuid("id").primaryKey().defaultRandom(),
10971126 slug: text("slug").notNull().unique(),
10981127 name: text("name").notNull(),
1128 displayName: text("display_name"), // added in 0113
10991129 description: text("description"),
11001130 avatarUrl: text("avatar_url"),
1131 website: text("website"), // added in 0113
1132 location: text("location"), // added in 0113
1133 isVerified: boolean("is_verified").default(false).notNull(), // added in 0113
11011134 billingEmail: text("billing_email"),
11021135 createdById: uuid("created_by_id")
11031136 .notNull()
@@ -1143,6 +1176,10 @@ export const teams = pgTable(
11431176 slug: text("slug").notNull(),
11441177 name: text("name").notNull(),
11451178 description: text("description"),
1179 // Default repo access this team grants. Added in 0114: the create form
1180 // collects it and the team page renders it, but the column only existed
1181 // in the rival schema-extensions definition, so it was never migrated.
1182 permission: text("permission").notNull().default("read"), // read | write | admin
11461183 parentTeamId: uuid("parent_team_id"),
11471184 createdAt: timestamp("created_at").defaultNow().notNull(),
11481185 updatedAt: timestamp("updated_at").defaultNow().notNull(),
@@ -1174,10 +1211,33 @@ export const teamMembers = pgTable(
11741211 ]
11751212);
11761213
1214/**
1215 * Repositories a team has been granted access to. Created in 0114 — this
1216 * table was declared only in schema-extensions.ts and therefore never had a
1217 * migration, so it did not exist in the database at all, while
1218 * src/routes/orgs.tsx selected and joined it to render a team's repo list.
1219 */
1220export const teamRepos = pgTable(
1221 "team_repos",
1222 {
1223 id: uuid("id").primaryKey().defaultRandom(),
1224 teamId: uuid("team_id")
1225 .notNull()
1226 .references(() => teams.id, { onDelete: "cascade" }),
1227 repositoryId: uuid("repository_id")
1228 .notNull()
1229 .references(() => repositories.id, { onDelete: "cascade" }),
1230 permission: text("permission").notNull().default("read"), // read | write | admin
1231 createdAt: timestamp("created_at").defaultNow().notNull(),
1232 },
1233 (table) => [uniqueIndex("team_repos_unique").on(table.teamId, table.repositoryId)]
1234);
1235
11771236export type Organization = typeof organizations.$inferSelect;
11781237export type OrgMember = typeof orgMembers.$inferSelect;
11791238export type Team = typeof teams.$inferSelect;
11801239export type TeamMember = typeof teamMembers.$inferSelect;
1240export type TeamRepo = typeof teamRepos.$inferSelect;
11811241export type OrgRole = "owner" | "admin" | "member";
11821242export type TeamRole = "maintainer" | "member";
11831243
Modifiedsrc/lib/notify.ts+9−2View fileUnifiedSplit
@@ -15,8 +15,14 @@ import { notifications as notificationsExt } from "../db/schema-extensions";
1515import { sendEmail, absoluteUrl } from "./email";
1616
1717// ─── Public helpers (schema-extensions notifications table) ─────────────────
18// These operate on the schema-extensions `notifications` table (which has
19// `type`, `isRead`, `actorId` columns) — the same table the inbox page uses.
18// There is one `notifications` table. It carries two parallel representations
19// of the same facts — `kind`/`read_at` (original) and `type`/`is_read` (added
20// by migration 0089) — because 0089 added columns rather than migrating data.
21// A second Drizzle definition in schema-extensions.ts described only the 0089
22// half, which hid the fact that `kind` is NOT NULL: this insert supplied only
23// `type`, so EVERY notification created through here violated the constraint
24// and was swallowed by the catch below. Write both until the representations
25// are consolidated (which needs a backfill).
2026
2127/** Create a single in-app notification. Fire-and-forget safe: swallows errors. */
2228export async function createNotification(params: {
@@ -30,6 +36,7 @@ export async function createNotification(params: {
3036 try {
3137 await db.insert(notificationsExt).values({
3238 userId: params.userId,
39 kind: params.type, // NOT NULL — was omitted, so every insert failed
3340 type: params.type,
3441 title: params.title,
3542 body: params.body,
Modifiedsrc/routes/notifications.tsx+16−2View fileUnifiedSplit
@@ -760,7 +760,14 @@ notificationRoutes.post("/notifications/:id/read", softAuth, requireAuth, async
760760 try {
761761 await db
762762 .update(notifications)
763 .set({ isRead: true })
763 // Both columns, deliberately. The table carries two independent
764 // read-state representations: `is_read` (added by migration 0089, used
765 // by this page and /inbox) and `read_at` (original, used by
766 // lib/unread.ts for the nav badge and lib/smart-digest.ts for the daily
767 // digest). Only `is_read` was ever written, so marking a notification
768 // read here left `read_at` NULL forever — the badge and the digest kept
769 // counting notifications the user had already read.
770 .set({ isRead: true, readAt: new Date() })
764771 .where(and(eq(notifications.id, id), eq(notifications.userId, user.id)));
765772 } catch {
766773 // Table may not exist
@@ -776,7 +783,14 @@ notificationRoutes.post("/notifications/read-all", softAuth, requireAuth, async
776783 try {
777784 await db
778785 .update(notifications)
779 .set({ isRead: true })
786 // Both columns, deliberately. The table carries two independent
787 // read-state representations: `is_read` (added by migration 0089, used
788 // by this page and /inbox) and `read_at` (original, used by
789 // lib/unread.ts for the nav badge and lib/smart-digest.ts for the daily
790 // digest). Only `is_read` was ever written, so marking a notification
791 // read here left `read_at` NULL forever — the badge and the digest kept
792 // counting notifications the user had already read.
793 .set({ isRead: true, readAt: new Date() })
780794 .where(and(eq(notifications.userId, user.id), eq(notifications.isRead, false)));
781795 } catch {
782796 // Table may not exist
Modifiedsrc/routes/orgs.tsx+29−0View fileUnifiedSplit
@@ -739,10 +739,22 @@ orgRoutes.post("/orgs/new", softAuth, requireAuth, async (c) => {
739739 return c.redirect("/orgs/new?error=Organization+already+exists");
740740 }
741741
742 // `slug` and `createdById` are NOT NULL with no default (migration 0003).
743 // Both were omitted here, so this insert has never succeeded — the two
744 // rival `organizations` definitions meant the typechecker could not see
745 // it. Now that schema-extensions re-exports the real table, tsc enforces
746 // the full column set.
747 //
748 // The route is /orgs/:org keyed on `name`, and `name` is already
749 // validated to /^[a-zA-Z0-9._-]+$/ and checked for collisions against
750 // both users and orgs above — so the slug is the lowercased name, which
751 // keeps the unique index consistent with the case-insensitive lookups.
742752 const [org] = await db
743753 .insert(organizations)
744754 .values({
745755 name,
756 slug: name.toLowerCase(),
757 createdById: user.id,
746758 displayName: displayName || name,
747759 description: description || null,
748760 website: website || null,
@@ -1427,9 +1439,26 @@ orgRoutes.post("/orgs/:org/teams/new", softAuth, requireAuth, async (c) => {
14271439 return c.redirect(`/orgs/${orgName}/teams/new?error=Team+name+is+required`);
14281440 }
14291441
1442 // `teams.slug` is NOT NULL and carries the unique (org_id, slug) index,
1443 // but this insert omitted it — the same latent bug as POST /orgs/new, and
1444 // invisible for the same reason: schema-extensions declared a rival
1445 // `teams` without the column, so tsc never checked it. Team names are
1446 // free text, so derive a URL-safe slug rather than reusing the name.
1447 const slug = name
1448 .toLowerCase()
1449 .normalize("NFKD")
1450 .replace(/[^a-z0-9\s-]/g, "")
1451 .replace(/\s+/g, "-")
1452 .replace(/-+/g, "-")
1453 .replace(/^-+|-+$/g, "");
1454 if (!slug) {
1455 return c.redirect(`/orgs/${orgName}/teams/new?error=Team+name+must+contain+letters+or+numbers`);
1456 }
1457
14301458 await db.insert(teams).values({
14311459 orgId: org.id,
14321460 name,
1461 slug,
14331462 description: description || null,
14341463 permission: ["read", "write", "admin"].includes(permission) ? permission : "read",
14351464 });
14361465