Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
Blame · Line-by-line history

schema.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

schema.tsBlame775 lines · 1 contributor
fc1817aClaude1import {
2 pgTable,
3 text,
4 timestamp,
5 uuid,
6 boolean,
7 integer,
8 uniqueIndex,
79136bbClaude9 index,
10 serial,
fc1817aClaude11} from "drizzle-orm/pg-core";
12
13export const users = pgTable("users", {
14 id: uuid("id").primaryKey().defaultRandom(),
15 username: text("username").notNull().unique(),
16 email: text("email").notNull().unique(),
17 displayName: text("display_name"),
18 passwordHash: text("password_hash").notNull(),
19 avatarUrl: text("avatar_url"),
20 bio: text("bio"),
24cf2caClaude21 // Email notification preferences (Block A8). Default on; opt-out via /settings.
22 notifyEmailOnMention: boolean("notify_email_on_mention").default(true).notNull(),
23 notifyEmailOnAssign: boolean("notify_email_on_assign").default(true).notNull(),
24 notifyEmailOnGateFail: boolean("notify_email_on_gate_fail").default(true).notNull(),
fc1817aClaude25 createdAt: timestamp("created_at").defaultNow().notNull(),
26 updatedAt: timestamp("updated_at").defaultNow().notNull(),
27});
28
06d5ffeClaude29export const sessions = pgTable("sessions", {
30 id: uuid("id").primaryKey().defaultRandom(),
31 userId: uuid("user_id")
32 .notNull()
33 .references(() => users.id, { onDelete: "cascade" }),
34 token: text("token").notNull().unique(),
35 expiresAt: timestamp("expires_at").notNull(),
36 createdAt: timestamp("created_at").defaultNow().notNull(),
37});
38
fc1817aClaude39export const repositories = pgTable(
40 "repositories",
41 {
42 id: uuid("id").primaryKey().defaultRandom(),
43 name: text("name").notNull(),
7437605Claude44 // ownerId = creator / user-owner. Always set (for attribution + user
45 // namespace uniqueness). For org-owned repos, also represents "created by".
fc1817aClaude46 ownerId: uuid("owner_id")
47 .notNull()
48 .references(() => users.id),
7437605Claude49 // Block B2: nullable org owner. When set, the repo lives in the org
50 // namespace and URL resolution routes `/:orgSlug/:repo` to it.
51 orgId: uuid("org_id"),
fc1817aClaude52 description: text("description"),
53 isPrivate: boolean("is_private").default(false).notNull(),
3ef4c9dClaude54 isArchived: boolean("is_archived").default(false).notNull(),
fc1817aClaude55 defaultBranch: text("default_branch").default("main").notNull(),
56 diskPath: text("disk_path").notNull(),
c81ab7aClaude57 forkedFromId: uuid("forked_from_id").references(() => repositories.id, {
58 onDelete: "set null",
59 }),
fc1817aClaude60 createdAt: timestamp("created_at").defaultNow().notNull(),
61 updatedAt: timestamp("updated_at").defaultNow().notNull(),
62 pushedAt: timestamp("pushed_at"),
63 starCount: integer("star_count").default(0).notNull(),
64 forkCount: integer("fork_count").default(0).notNull(),
79136bbClaude65 issueCount: integer("issue_count").default(0).notNull(),
fc1817aClaude66 },
7437605Claude67 (table) => [
68 // Partial: uniqueness only in the user namespace (org-owned rows exempt).
69 // Matches the partial index in migration 0004.
70 uniqueIndex("repos_owner_name").on(table.ownerId, table.name),
71 index("repos_org").on(table.orgId),
72 ]
fc1817aClaude73);
74
3ef4c9dClaude75/**
76 * Per-repository gate + auto-repair configuration.
77 * Every new repo is created with all gates ENABLED by default —
78 * the "full green ecosystem" default. Owners can manually opt-out per setting.
79 */
80export const repoSettings = pgTable("repo_settings", {
81 id: uuid("id").primaryKey().defaultRandom(),
82 repositoryId: uuid("repository_id")
83 .notNull()
84 .unique()
85 .references(() => repositories.id, { onDelete: "cascade" }),
86 // Gates
87 gateTestEnabled: boolean("gate_test_enabled").default(true).notNull(),
88 aiReviewEnabled: boolean("ai_review_enabled").default(true).notNull(),
89 secretScanEnabled: boolean("secret_scan_enabled").default(true).notNull(),
90 securityScanEnabled: boolean("security_scan_enabled").default(true).notNull(),
91 dependencyScanEnabled: boolean("dependency_scan_enabled").default(true).notNull(),
92 lintEnabled: boolean("lint_enabled").default(true).notNull(),
93 typeCheckEnabled: boolean("type_check_enabled").default(true).notNull(),
94 testEnabled: boolean("test_enabled").default(true).notNull(),
95 // Auto-repair
96 autoFixEnabled: boolean("auto_fix_enabled").default(true).notNull(),
97 autoMergeResolveEnabled: boolean("auto_merge_resolve_enabled").default(true).notNull(),
98 autoFormatEnabled: boolean("auto_format_enabled").default(true).notNull(),
99 // AI features
100 aiCommitMessagesEnabled: boolean("ai_commit_messages_enabled").default(true).notNull(),
101 aiPrSummaryEnabled: boolean("ai_pr_summary_enabled").default(true).notNull(),
102 aiChangelogEnabled: boolean("ai_changelog_enabled").default(true).notNull(),
103 // Deploy
104 autoDeployEnabled: boolean("auto_deploy_enabled").default(true).notNull(),
105 deployRequireAllGreen: boolean("deploy_require_all_green").default(true).notNull(),
106 createdAt: timestamp("created_at").defaultNow().notNull(),
107 updatedAt: timestamp("updated_at").defaultNow().notNull(),
108});
109
110/**
111 * Branch protection rules — enforced on push and merge.
112 * Every repo's default branch gets a protection rule on creation.
113 */
114export const branchProtection = pgTable(
115 "branch_protection",
116 {
117 id: uuid("id").primaryKey().defaultRandom(),
118 repositoryId: uuid("repository_id")
119 .notNull()
120 .references(() => repositories.id, { onDelete: "cascade" }),
121 pattern: text("pattern").notNull(), // branch name or glob (e.g. "main", "release/*")
122 requirePullRequest: boolean("require_pull_request").default(true).notNull(),
123 requireGreenGates: boolean("require_green_gates").default(true).notNull(),
124 requireAiApproval: boolean("require_ai_approval").default(true).notNull(),
125 requireHumanReview: boolean("require_human_review").default(false).notNull(),
126 requiredApprovals: integer("required_approvals").default(0).notNull(),
127 allowForcePush: boolean("allow_force_push").default(false).notNull(),
128 allowDeletion: boolean("allow_deletion").default(false).notNull(),
129 dismissStaleReviews: boolean("dismiss_stale_reviews").default(true).notNull(),
130 createdAt: timestamp("created_at").defaultNow().notNull(),
131 updatedAt: timestamp("updated_at").defaultNow().notNull(),
132 },
133 (table) => [
134 uniqueIndex("branch_protection_repo_pattern").on(
135 table.repositoryId,
136 table.pattern
137 ),
138 ]
139);
140
141/**
142 * Gate run history. Every push + every PR creates gate_runs entries —
143 * one per configured gate. Serves as the source of truth for "is this green?".
144 */
145export const gateRuns = pgTable(
146 "gate_runs",
147 {
148 id: uuid("id").primaryKey().defaultRandom(),
149 repositoryId: uuid("repository_id")
150 .notNull()
151 .references(() => repositories.id, { onDelete: "cascade" }),
152 pullRequestId: uuid("pull_request_id").references(() => pullRequests.id, {
153 onDelete: "cascade",
154 }),
155 commitSha: text("commit_sha").notNull(),
156 ref: text("ref").notNull(),
157 gateName: text("gate_name").notNull(), // e.g. "GateTest", "AI Review", "Secret Scan", "Type Check"
158 status: text("status").notNull(), // pending, running, passed, failed, skipped, repaired
159 summary: text("summary"),
160 details: text("details"), // JSON: per-check output, affected files, etc
161 repairAttempted: boolean("repair_attempted").default(false).notNull(),
162 repairSucceeded: boolean("repair_succeeded").default(false).notNull(),
163 repairCommitSha: text("repair_commit_sha"),
164 durationMs: integer("duration_ms"),
165 createdAt: timestamp("created_at").defaultNow().notNull(),
166 completedAt: timestamp("completed_at"),
167 },
168 (table) => [
169 index("gate_runs_repo_sha").on(table.repositoryId, table.commitSha),
170 index("gate_runs_pr").on(table.pullRequestId),
171 index("gate_runs_created").on(table.createdAt),
172 ]
173);
174
175/**
176 * In-app notifications. Powered by the activity feed + explicit mentions.
177 */
178export const notifications = pgTable(
179 "notifications",
180 {
181 id: uuid("id").primaryKey().defaultRandom(),
182 userId: uuid("user_id")
183 .notNull()
184 .references(() => users.id, { onDelete: "cascade" }),
185 repositoryId: uuid("repository_id").references(() => repositories.id, {
186 onDelete: "cascade",
187 }),
188 kind: text("kind").notNull(), // mention, review_requested, pr_merged, pr_closed, gate_failed, gate_repaired, ai_review, assigned, security_alert, deploy_success, deploy_failed
189 title: text("title").notNull(),
190 body: text("body"),
191 url: text("url"), // link to the relevant page
192 readAt: timestamp("read_at"),
193 createdAt: timestamp("created_at").defaultNow().notNull(),
194 },
195 (table) => [
196 index("notifications_user_unread").on(table.userId, table.readAt),
197 index("notifications_user_created").on(table.userId, table.createdAt),
198 ]
199);
200
201/**
202 * Releases — named snapshots of a repo at a tag/commit.
203 * AI-generated changelogs bundled in notes field.
204 */
205export const releases = pgTable(
206 "releases",
207 {
208 id: uuid("id").primaryKey().defaultRandom(),
209 repositoryId: uuid("repository_id")
210 .notNull()
211 .references(() => repositories.id, { onDelete: "cascade" }),
212 authorId: uuid("author_id")
213 .notNull()
214 .references(() => users.id),
215 tag: text("tag").notNull(),
216 name: text("name").notNull(),
217 body: text("body"), // AI-generated release notes + changelog
218 targetCommit: text("target_commit").notNull(),
219 isDraft: boolean("is_draft").default(false).notNull(),
220 isPrerelease: boolean("is_prerelease").default(false).notNull(),
221 createdAt: timestamp("created_at").defaultNow().notNull(),
222 publishedAt: timestamp("published_at"),
223 },
224 (table) => [
225 uniqueIndex("releases_repo_tag").on(table.repositoryId, table.tag),
226 ]
227);
228
229/**
230 * Milestones — group issues + PRs toward a shared goal.
231 */
232export const milestones = pgTable(
233 "milestones",
234 {
235 id: uuid("id").primaryKey().defaultRandom(),
236 repositoryId: uuid("repository_id")
237 .notNull()
238 .references(() => repositories.id, { onDelete: "cascade" }),
239 title: text("title").notNull(),
240 description: text("description"),
241 state: text("state").notNull().default("open"), // open, closed
242 dueDate: timestamp("due_date"),
243 createdAt: timestamp("created_at").defaultNow().notNull(),
244 closedAt: timestamp("closed_at"),
245 },
246 (table) => [index("milestones_repo_state").on(table.repositoryId, table.state)]
247);
248
249/**
250 * Reactions on issues, PRs, and comments. Universal target pointer.
251 */
252export const reactions = pgTable(
253 "reactions",
254 {
255 id: uuid("id").primaryKey().defaultRandom(),
256 userId: uuid("user_id")
257 .notNull()
258 .references(() => users.id, { onDelete: "cascade" }),
259 targetType: text("target_type").notNull(), // issue, pr, issue_comment, pr_comment
260 targetId: uuid("target_id").notNull(),
261 emoji: text("emoji").notNull(), // thumbs_up, thumbs_down, rocket, heart, eyes, laugh, hooray, confused
262 createdAt: timestamp("created_at").defaultNow().notNull(),
263 },
264 (table) => [
265 uniqueIndex("reactions_unique").on(
266 table.userId,
267 table.targetType,
268 table.targetId,
269 table.emoji
270 ),
271 index("reactions_target").on(table.targetType, table.targetId),
272 ]
273);
274
275/**
276 * PR reviews (formal approve/request-changes).
277 * Separate from inline comments in pr_comments.
278 */
279export const prReviews = pgTable(
280 "pr_reviews",
281 {
282 id: uuid("id").primaryKey().defaultRandom(),
283 pullRequestId: uuid("pull_request_id")
284 .notNull()
285 .references(() => pullRequests.id, { onDelete: "cascade" }),
286 reviewerId: uuid("reviewer_id")
287 .notNull()
288 .references(() => users.id),
289 state: text("state").notNull(), // approved, changes_requested, commented
290 body: text("body"),
291 isAi: boolean("is_ai").default(false).notNull(),
292 createdAt: timestamp("created_at").defaultNow().notNull(),
293 },
294 (table) => [index("pr_reviews_pr").on(table.pullRequestId)]
295);
296
297/**
298 * Code owners — who owns which paths (auto-request review on PR).
299 * Parsed from a CODEOWNERS file at the root of the default branch.
300 */
301export const codeOwners = pgTable(
302 "code_owners",
303 {
304 id: uuid("id").primaryKey().defaultRandom(),
305 repositoryId: uuid("repository_id")
306 .notNull()
307 .references(() => repositories.id, { onDelete: "cascade" }),
308 pathPattern: text("path_pattern").notNull(),
309 ownerUsernames: text("owner_usernames").notNull(), // comma-separated
310 createdAt: timestamp("created_at").defaultNow().notNull(),
311 },
312 (table) => [index("code_owners_repo").on(table.repositoryId)]
313);
314
315/**
316 * Per-repo AI chat sessions — conversational repo assistant.
317 */
318export const aiChats = pgTable(
319 "ai_chats",
320 {
321 id: uuid("id").primaryKey().defaultRandom(),
322 userId: uuid("user_id")
323 .notNull()
324 .references(() => users.id, { onDelete: "cascade" }),
325 repositoryId: uuid("repository_id").references(() => repositories.id, {
326 onDelete: "cascade",
327 }),
328 title: text("title"),
329 messages: text("messages").notNull().default("[]"), // JSON array of {role, content}
330 createdAt: timestamp("created_at").defaultNow().notNull(),
331 updatedAt: timestamp("updated_at").defaultNow().notNull(),
332 },
333 (table) => [
334 index("ai_chats_user").on(table.userId),
335 index("ai_chats_repo").on(table.repositoryId),
336 ]
337);
338
339/**
340 * Audit log — every sensitive action. Who did what, when, from where.
341 */
342export const auditLog = pgTable(
343 "audit_log",
344 {
345 id: uuid("id").primaryKey().defaultRandom(),
346 userId: uuid("user_id").references(() => users.id, { onDelete: "set null" }),
347 repositoryId: uuid("repository_id").references(() => repositories.id, {
348 onDelete: "set null",
349 }),
350 action: text("action").notNull(), // repo.create, repo.delete, repo.transfer, token.create, token.revoke, merge, force_push, branch_protection.update, deploy, ...
351 targetType: text("target_type"),
352 targetId: text("target_id"),
353 ip: text("ip"),
354 userAgent: text("user_agent"),
355 metadata: text("metadata"), // JSON for extra context
356 createdAt: timestamp("created_at").defaultNow().notNull(),
357 },
358 (table) => [
359 index("audit_log_user").on(table.userId),
360 index("audit_log_repo").on(table.repositoryId),
361 index("audit_log_created").on(table.createdAt),
362 ]
363);
364
365/**
366 * Deployments — tracks every deploy to downstream systems (Crontech, etc).
367 * Each deploy is gated on ALL green gates passing.
368 */
369export const deployments = pgTable(
370 "deployments",
371 {
372 id: uuid("id").primaryKey().defaultRandom(),
373 repositoryId: uuid("repository_id")
374 .notNull()
375 .references(() => repositories.id, { onDelete: "cascade" }),
376 environment: text("environment").notNull().default("production"),
377 commitSha: text("commit_sha").notNull(),
378 ref: text("ref").notNull(),
379 status: text("status").notNull(), // pending, running, success, failed, blocked
380 blockedReason: text("blocked_reason"),
381 target: text("target"), // e.g. "crontech", "fly.io"
382 triggeredBy: uuid("triggered_by").references(() => users.id),
383 createdAt: timestamp("created_at").defaultNow().notNull(),
384 completedAt: timestamp("completed_at"),
385 },
386 (table) => [
387 index("deployments_repo").on(table.repositoryId),
388 index("deployments_created").on(table.createdAt),
389 ]
390);
391
392/**
393 * Rate-limit buckets — in-memory or persisted counter per IP / token / route.
394 */
395export const rateLimitBuckets = pgTable(
396 "rate_limit_buckets",
397 {
398 id: uuid("id").primaryKey().defaultRandom(),
399 bucketKey: text("bucket_key").notNull().unique(), // "ip:1.2.3.4:api" or "token:abc:api"
400 count: integer("count").default(0).notNull(),
401 windowStart: timestamp("window_start").defaultNow().notNull(),
402 expiresAt: timestamp("expires_at").notNull(),
403 },
404 (table) => [index("rate_limit_expires").on(table.expiresAt)]
405);
406
06d5ffeClaude407export const stars = pgTable(
408 "stars",
409 {
410 id: uuid("id").primaryKey().defaultRandom(),
411 userId: uuid("user_id")
412 .notNull()
413 .references(() => users.id, { onDelete: "cascade" }),
414 repositoryId: uuid("repository_id")
415 .notNull()
416 .references(() => repositories.id, { onDelete: "cascade" }),
417 createdAt: timestamp("created_at").defaultNow().notNull(),
418 },
79136bbClaude419 (table) => [
420 uniqueIndex("stars_user_repo").on(table.userId, table.repositoryId),
421 ]
422);
423
424export const issues = pgTable(
425 "issues",
426 {
427 id: uuid("id").primaryKey().defaultRandom(),
428 number: serial("number"),
429 repositoryId: uuid("repository_id")
430 .notNull()
431 .references(() => repositories.id, { onDelete: "cascade" }),
432 authorId: uuid("author_id")
433 .notNull()
434 .references(() => users.id),
435 title: text("title").notNull(),
436 body: text("body"),
437 state: text("state").notNull().default("open"), // open, closed
438 createdAt: timestamp("created_at").defaultNow().notNull(),
439 updatedAt: timestamp("updated_at").defaultNow().notNull(),
440 closedAt: timestamp("closed_at"),
441 },
442 (table) => [
443 index("issues_repo_state").on(table.repositoryId, table.state),
444 index("issues_repo_number").on(table.repositoryId, table.number),
445 ]
446);
447
448export const issueComments = pgTable(
449 "issue_comments",
450 {
451 id: uuid("id").primaryKey().defaultRandom(),
452 issueId: uuid("issue_id")
453 .notNull()
454 .references(() => issues.id, { onDelete: "cascade" }),
455 authorId: uuid("author_id")
456 .notNull()
457 .references(() => users.id),
458 body: text("body").notNull(),
459 createdAt: timestamp("created_at").defaultNow().notNull(),
460 updatedAt: timestamp("updated_at").defaultNow().notNull(),
461 },
462 (table) => [index("comments_issue").on(table.issueId)]
463);
464
465export const labels = pgTable(
466 "labels",
467 {
468 id: uuid("id").primaryKey().defaultRandom(),
469 repositoryId: uuid("repository_id")
470 .notNull()
471 .references(() => repositories.id, { onDelete: "cascade" }),
472 name: text("name").notNull(),
473 color: text("color").notNull().default("#8b949e"),
474 description: text("description"),
475 },
476 (table) => [
477 uniqueIndex("labels_repo_name").on(table.repositoryId, table.name),
478 ]
479);
480
481export const issueLabels = pgTable(
482 "issue_labels",
483 {
484 id: uuid("id").primaryKey().defaultRandom(),
485 issueId: uuid("issue_id")
486 .notNull()
487 .references(() => issues.id, { onDelete: "cascade" }),
488 labelId: uuid("label_id")
489 .notNull()
490 .references(() => labels.id, { onDelete: "cascade" }),
491 },
492 (table) => [
493 uniqueIndex("issue_labels_unique").on(table.issueId, table.labelId),
494 ]
06d5ffeClaude495);
496
0074234Claude497export const pullRequests = pgTable(
498 "pull_requests",
499 {
500 id: uuid("id").primaryKey().defaultRandom(),
501 number: serial("number"),
502 repositoryId: uuid("repository_id")
503 .notNull()
504 .references(() => repositories.id, { onDelete: "cascade" }),
505 authorId: uuid("author_id")
506 .notNull()
507 .references(() => users.id),
508 title: text("title").notNull(),
509 body: text("body"),
510 state: text("state").notNull().default("open"), // open, closed, merged
511 baseBranch: text("base_branch").notNull(),
512 headBranch: text("head_branch").notNull(),
3ef4c9dClaude513 isDraft: boolean("is_draft").default(false).notNull(),
514 mergeStrategy: text("merge_strategy").default("merge").notNull(), // merge, squash, rebase
515 milestoneId: uuid("milestone_id"),
0074234Claude516 mergedAt: timestamp("merged_at"),
517 mergedBy: uuid("merged_by").references(() => users.id),
518 createdAt: timestamp("created_at").defaultNow().notNull(),
519 updatedAt: timestamp("updated_at").defaultNow().notNull(),
520 closedAt: timestamp("closed_at"),
521 },
522 (table) => [
523 index("prs_repo_state").on(table.repositoryId, table.state),
524 index("prs_repo_number").on(table.repositoryId, table.number),
525 ]
526);
527
528export const prComments = pgTable(
529 "pr_comments",
530 {
531 id: uuid("id").primaryKey().defaultRandom(),
532 pullRequestId: uuid("pull_request_id")
533 .notNull()
534 .references(() => pullRequests.id, { onDelete: "cascade" }),
535 authorId: uuid("author_id")
536 .notNull()
537 .references(() => users.id),
538 body: text("body").notNull(),
539 isAiReview: boolean("is_ai_review").default(false).notNull(),
540 filePath: text("file_path"),
541 lineNumber: integer("line_number"),
542 createdAt: timestamp("created_at").defaultNow().notNull(),
543 updatedAt: timestamp("updated_at").defaultNow().notNull(),
544 },
545 (table) => [index("pr_comments_pr").on(table.pullRequestId)]
546);
547
548export const activityFeed = pgTable(
549 "activity_feed",
550 {
551 id: uuid("id").primaryKey().defaultRandom(),
552 repositoryId: uuid("repository_id")
553 .notNull()
554 .references(() => repositories.id, { onDelete: "cascade" }),
555 userId: uuid("user_id").references(() => users.id),
556 action: text("action").notNull(), // push, issue_open, issue_close, pr_open, pr_merge, star, comment
557 targetType: text("target_type"), // issue, pr, commit
558 targetId: text("target_id"),
559 metadata: text("metadata"), // JSON string for extra data
560 createdAt: timestamp("created_at").defaultNow().notNull(),
561 },
562 (table) => [
563 index("activity_repo").on(table.repositoryId),
564 index("activity_user").on(table.userId),
565 ]
566);
567
c81ab7aClaude568export const webhooks = pgTable(
569 "webhooks",
570 {
571 id: uuid("id").primaryKey().defaultRandom(),
572 repositoryId: uuid("repository_id")
573 .notNull()
574 .references(() => repositories.id, { onDelete: "cascade" }),
575 url: text("url").notNull(),
576 secret: text("secret"),
577 events: text("events").notNull().default("push"), // comma-separated: push,issue,pr
578 isActive: boolean("is_active").default(true).notNull(),
579 lastDeliveredAt: timestamp("last_delivered_at"),
580 lastStatus: integer("last_status"),
581 createdAt: timestamp("created_at").defaultNow().notNull(),
582 },
583 (table) => [index("webhooks_repo").on(table.repositoryId)]
584);
585
586export const apiTokens = pgTable("api_tokens", {
587 id: uuid("id").primaryKey().defaultRandom(),
588 userId: uuid("user_id")
589 .notNull()
590 .references(() => users.id, { onDelete: "cascade" }),
591 name: text("name").notNull(),
592 tokenHash: text("token_hash").notNull(),
593 tokenPrefix: text("token_prefix").notNull(), // first 8 chars for display
594 scopes: text("scopes").notNull().default("repo"), // comma-separated
595 lastUsedAt: timestamp("last_used_at"),
596 expiresAt: timestamp("expires_at"),
597 createdAt: timestamp("created_at").defaultNow().notNull(),
598});
599
600export const repoTopics = pgTable(
601 "repo_topics",
602 {
603 id: uuid("id").primaryKey().defaultRandom(),
604 repositoryId: uuid("repository_id")
605 .notNull()
606 .references(() => repositories.id, { onDelete: "cascade" }),
607 topic: text("topic").notNull(),
608 },
609 (table) => [
610 uniqueIndex("repo_topics_unique").on(table.repositoryId, table.topic),
611 index("topics_name").on(table.topic),
612 ]
613);
614
fc1817aClaude615export const sshKeys = pgTable("ssh_keys", {
616 id: uuid("id").primaryKey().defaultRandom(),
617 userId: uuid("user_id")
618 .notNull()
06d5ffeClaude619 .references(() => users.id, { onDelete: "cascade" }),
fc1817aClaude620 title: text("title").notNull(),
621 fingerprint: text("fingerprint").notNull(),
622 publicKey: text("public_key").notNull(),
623 lastUsedAt: timestamp("last_used_at"),
624 createdAt: timestamp("created_at").defaultNow().notNull(),
625});
626
627export type User = typeof users.$inferSelect;
628export type NewUser = typeof users.$inferInsert;
629export type Repository = typeof repositories.$inferSelect;
630export type NewRepository = typeof repositories.$inferInsert;
06d5ffeClaude631export type Session = typeof sessions.$inferSelect;
632export type Star = typeof stars.$inferSelect;
633export type SshKey = typeof sshKeys.$inferSelect;
79136bbClaude634export type Issue = typeof issues.$inferSelect;
635export type IssueComment = typeof issueComments.$inferSelect;
636export type Label = typeof labels.$inferSelect;
0074234Claude637export type PullRequest = typeof pullRequests.$inferSelect;
638export type PrComment = typeof prComments.$inferSelect;
639export type ActivityEntry = typeof activityFeed.$inferSelect;
c81ab7aClaude640export type Webhook = typeof webhooks.$inferSelect;
641export type ApiToken = typeof apiTokens.$inferSelect;
642export type RepoTopic = typeof repoTopics.$inferSelect;
3ef4c9dClaude643export type RepoSettings = typeof repoSettings.$inferSelect;
644export type BranchProtection = typeof branchProtection.$inferSelect;
645export type GateRun = typeof gateRuns.$inferSelect;
646export type Notification = typeof notifications.$inferSelect;
647export type Release = typeof releases.$inferSelect;
648export type Milestone = typeof milestones.$inferSelect;
649export type Reaction = typeof reactions.$inferSelect;
650export type PrReview = typeof prReviews.$inferSelect;
651export type CodeOwner = typeof codeOwners.$inferSelect;
652export type AiChat = typeof aiChats.$inferSelect;
653export type AuditLogEntry = typeof auditLog.$inferSelect;
654export type Deployment = typeof deployments.$inferSelect;
24cf2caClaude655
656/**
657 * Saved replies — per-user canned responses, insertable into any
658 * issue / PR comment textarea. Shortcut name must be unique per user.
659 */
660export const savedReplies = pgTable(
661 "saved_replies",
662 {
663 id: uuid("id").primaryKey().defaultRandom(),
664 userId: uuid("user_id")
665 .notNull()
666 .references(() => users.id, { onDelete: "cascade" }),
667 shortcut: text("shortcut").notNull(),
668 body: text("body").notNull(),
669 createdAt: timestamp("created_at").defaultNow().notNull(),
670 updatedAt: timestamp("updated_at").defaultNow().notNull(),
671 },
672 (table) => [
673 uniqueIndex("saved_replies_user_shortcut").on(table.userId, table.shortcut),
674 ]
675);
676
677export type SavedReply = typeof savedReplies.$inferSelect;
5cc5d95Claude678
679/**
680 * Organizations (Block B1) — multi-user namespaces. Distinct from `users`.
681 * An org has members (with org-level roles) and may contain teams.
682 * Repos can be owned by an org via `repositories.orgId` (added in Block B2).
683 *
684 * Slug is globally unique against itself; collision with a username is
685 * checked at create time in the route handler (no DB-level cross-table
686 * uniqueness in Postgres).
687 */
688export const organizations = pgTable("organizations", {
689 id: uuid("id").primaryKey().defaultRandom(),
690 slug: text("slug").notNull().unique(),
691 name: text("name").notNull(),
692 description: text("description"),
693 avatarUrl: text("avatar_url"),
694 billingEmail: text("billing_email"),
695 createdById: uuid("created_by_id")
696 .notNull()
697 .references(() => users.id, { onDelete: "restrict" }),
698 createdAt: timestamp("created_at").defaultNow().notNull(),
699 updatedAt: timestamp("updated_at").defaultNow().notNull(),
700});
701
702/**
703 * Org membership. Roles: owner (full control, billing), admin (manage
704 * members + teams + repos), member (default; can be added to teams).
705 */
706export const orgMembers = pgTable(
707 "org_members",
708 {
709 id: uuid("id").primaryKey().defaultRandom(),
710 orgId: uuid("org_id")
711 .notNull()
712 .references(() => organizations.id, { onDelete: "cascade" }),
713 userId: uuid("user_id")
714 .notNull()
715 .references(() => users.id, { onDelete: "cascade" }),
716 role: text("role").notNull().default("member"), // owner | admin | member
717 createdAt: timestamp("created_at").defaultNow().notNull(),
718 },
719 (table) => [
720 uniqueIndex("org_members_unique").on(table.orgId, table.userId),
721 index("org_members_user").on(table.userId),
722 ]
723);
724
725/**
726 * Teams within an org. Slug is unique within an org.
727 * `parentTeamId` allows nesting (GitHub-style child teams). Optional.
728 */
729export const teams = pgTable(
730 "teams",
731 {
732 id: uuid("id").primaryKey().defaultRandom(),
733 orgId: uuid("org_id")
734 .notNull()
735 .references(() => organizations.id, { onDelete: "cascade" }),
736 slug: text("slug").notNull(),
737 name: text("name").notNull(),
738 description: text("description"),
739 parentTeamId: uuid("parent_team_id"),
740 createdAt: timestamp("created_at").defaultNow().notNull(),
741 updatedAt: timestamp("updated_at").defaultNow().notNull(),
742 },
743 (table) => [uniqueIndex("teams_org_slug").on(table.orgId, table.slug)]
744);
745
746/**
747 * Team membership. Roles: maintainer (can edit team), member (default).
748 * A user can belong to many teams; team membership requires org membership
749 * but that invariant is enforced at the route layer, not the DB layer.
750 */
751export const teamMembers = pgTable(
752 "team_members",
753 {
754 id: uuid("id").primaryKey().defaultRandom(),
755 teamId: uuid("team_id")
756 .notNull()
757 .references(() => teams.id, { onDelete: "cascade" }),
758 userId: uuid("user_id")
759 .notNull()
760 .references(() => users.id, { onDelete: "cascade" }),
761 role: text("role").notNull().default("member"), // maintainer | member
762 createdAt: timestamp("created_at").defaultNow().notNull(),
763 },
764 (table) => [
765 uniqueIndex("team_members_unique").on(table.teamId, table.userId),
766 index("team_members_user").on(table.userId),
767 ]
768);
769
770export type Organization = typeof organizations.$inferSelect;
771export type OrgMember = typeof orgMembers.$inferSelect;
772export type Team = typeof teams.$inferSelect;
773export type TeamMember = typeof teamMembers.$inferSelect;
774export type OrgRole = "owner" | "admin" | "member";
775export type TeamRole = "maintainer" | "member";