Files
growqr-backend/src/db/schema.ts
2026-06-14 10:06:34 +00:00

601 lines
26 KiB
TypeScript

import { sql } from "drizzle-orm";
import {
pgTable,
text,
timestamp,
integer,
boolean,
jsonb,
doublePrecision,
uniqueIndex,
index,
primaryKey,
} from "drizzle-orm/pg-core";
// Users are mirrored from Clerk on first sign-in.
// id = Clerk user id (e.g., "user_2abc..."), email is the canonical Clerk email.
export const users = pgTable(
"users",
{
id: text("id").primaryKey(),
email: text("email").notNull(),
displayName: text("display_name"),
createdAt: timestamp("created_at", { withTimezone: true })
.defaultNow()
.notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.defaultNow()
.notNull(),
},
(t) => ({
emailIdx: uniqueIndex("users_email_idx").on(t.email),
}),
);
// One per user. Tracks the user's unified agent's container stack + Git repo.
// Per changes.md §2A: per-user Gitea containers removed; central Gitea shared.
// Per changes.md §5: ONE actor per user manages the full orchestration layer.
export const userStacks = pgTable(
"user_stacks",
{
userId: text("user_id")
.primaryKey()
.references(() => users.id, { onDelete: "cascade" }),
status: text("status", {
enum: ["provisioning", "running", "stopped", "error"],
})
.notNull()
.default("provisioning"),
// Central Gitea (shared org-wide, changes.md §2A).
giteaRepoName: text("gitea_repo_name"),
giteaRepoOwner: text("gitea_repo_owner"),
// Per-user OpenCode container (from shared image, changes.md §3).
opencodeContainerId: text("opencode_container_id"),
opencodeContainerName: text("opencode_container_name"),
opencodeHost: text("opencode_host"),
opencodePort: integer("opencode_port"),
opencodePassword: text("opencode_password"),
workspacePath: text("workspace_path"),
// Version tracking for image rollouts (changes.md §9).
imageVersion: text("image_version"),
migrationVersion: text("migration_version"),
promptVersion: text("prompt_version"),
lastError: text("last_error"),
createdAt: timestamp("created_at", { withTimezone: true })
.defaultNow()
.notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true })
.defaultNow()
.notNull(),
},
(t) => ({
statusIdx: index("user_stacks_status_idx").on(t.status),
}),
);
// Per changes.md §5: ONE unified actor per user (no separate grow/sub actors).
// The actor manages: infra state, git state, runtime comms, migrations, API orchestration.
export const actors = pgTable(
"actors",
{
actorId: text("actor_id").notNull(),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
kind: text("kind", { enum: ["user"] }).notNull().default("user"),
status: text("status", {
enum: ["idle", "running", "done", "error"],
})
.notNull()
.default("idle"),
lastActivityAt: timestamp("last_activity_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true })
.defaultNow()
.notNull(),
},
(t) => ({
pk: primaryKey({ columns: [t.userId, t.actorId] }),
kindIdx: index("actors_user_kind_idx").on(t.userId, t.kind),
}),
);
// Per-user repo registry (in addition to the primary memory repo).
export const repos = pgTable(
"repos",
{
id: text("id").primaryKey(), // `${userId}:${name}`
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
name: text("name").notNull(),
role: text("role", { enum: ["memory", "project"] }).notNull(),
giteaOwner: text("gitea_owner").notNull(),
giteaName: text("gitea_name").notNull(),
createdAt: timestamp("created_at", { withTimezone: true })
.defaultNow()
.notNull(),
},
(t) => ({
userRoleIdx: index("repos_user_role_idx").on(t.userId, t.role),
}),
);
// OpenCode sessions opened by sub-agents.
export const opencodeSessions = pgTable(
"opencode_sessions",
{
id: text("id").primaryKey(), // OpenCode session id from POST /session
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
actorId: text("actor_id"),
title: text("title"),
parentId: text("parent_id"),
createdAt: timestamp("created_at", { withTimezone: true })
.defaultNow()
.notNull(),
},
(t) => ({
userIdx: index("opencode_sessions_user_idx").on(t.userId),
}),
);
// Audit/event log — small append-only stream used for debugging + the
// frontend's "task progress timeline" until we move it to Rivet streams only.
export const events = pgTable(
"events",
{
id: text("id")
.primaryKey()
.default(sql`gen_random_uuid()::text`),
userId: text("user_id").notNull(),
actorId: text("actor_id"),
type: text("type").notNull(),
payload: jsonb("payload").$type<Record<string, unknown>>(),
createdAt: timestamp("created_at", { withTimezone: true })
.defaultNow()
.notNull(),
},
(t) => ({
userIdx: index("events_user_idx").on(t.userId, t.createdAt),
}),
);
export type User = typeof users.$inferSelect;
export type NewUser = typeof users.$inferInsert;
export type UserStack = typeof userStacks.$inferSelect;
export type NewUserStack = typeof userStacks.$inferInsert;
export type ActorRow = typeof actors.$inferSelect;
export type RepoRow = typeof repos.$inferSelect;
export const missionRegistry = pgTable(
"mission_registry",
{
id: text("id").primaryKey(),
version: text("version").notNull(),
title: text("title").notNull(),
shortTitle: text("short_title").notNull(),
actorType: text("actor_type"),
actorBacked: boolean("actor_backed").notNull().default(false),
skillPath: text("skill_path").notNull(),
displayOrder: integer("display_order").notNull(),
definition: jsonb("definition").$type<Record<string, unknown>>().notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
},
(t) => ({ displayIdx: index("mission_registry_display_idx").on(t.displayOrder) }),
);
export type MissionRegistryRow = typeof missionRegistry.$inferSelect;
export const workflowRuns = pgTable(
"workflow_runs",
{
id: text("id").primaryKey().default(sql`gen_random_uuid()::text`),
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
workflowId: text("workflow_id").notNull(),
workflowVersion: text("workflow_version").notNull(),
status: text("status", { enum: ["draft", "running", "paused", "completed", "failed"] }).notNull().default("running"),
goal: text("goal"),
input: jsonb("input").$type<Record<string, unknown>>(),
currentStepId: text("current_step_id"),
progressPercent: integer("progress_percent").notNull().default(0),
qscoreBefore: jsonb("qscore_before").$type<Record<string, unknown>>(),
qscoreAfter: jsonb("qscore_after").$type<Record<string, unknown>>(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
completedAt: timestamp("completed_at", { withTimezone: true }),
},
(t) => ({ userIdx: index("workflow_runs_user_idx").on(t.userId, t.createdAt), workflowIdx: index("workflow_runs_workflow_idx").on(t.workflowId) }),
);
export const workflowRunModules = pgTable("workflow_run_modules", {
id: text("id").primaryKey().default(sql`gen_random_uuid()::text`),
runId: text("run_id").notNull().references(() => workflowRuns.id, { onDelete: "cascade" }),
moduleId: text("module_id").notNull(),
title: text("title").notNull(),
status: text("status").notNull().default("idle"),
service: text("service"),
idempotencyKey: text("idempotency_key"),
retryCount: integer("retry_count").notNull().default(0),
maxRetries: integer("max_retries").notNull().default(2),
outputSummary: text("output_summary"),
output: jsonb("output").$type<Record<string, unknown>>(),
error: text("error"),
startedAt: timestamp("started_at", { withTimezone: true }),
completedAt: timestamp("completed_at", { withTimezone: true }),
});
export const workflowArtifacts = pgTable("workflow_artifacts", {
id: text("id").primaryKey().default(sql`gen_random_uuid()::text`),
runId: text("run_id").notNull().references(() => workflowRuns.id, { onDelete: "cascade" }),
moduleId: text("module_id"),
type: text("type").notNull(),
title: text("title").notNull(),
repoPath: text("repo_path"),
publicUrl: text("public_url"),
metadata: jsonb("metadata").$type<Record<string, unknown>>(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
});
export const workflowApprovals = pgTable("workflow_approvals", {
id: text("id").primaryKey().default(sql`gen_random_uuid()::text`),
runId: text("run_id").notNull().references(() => workflowRuns.id, { onDelete: "cascade" }),
approvalId: text("approval_id").notNull(),
status: text("status", { enum: ["pending", "approved", "rejected"] }).notNull().default("pending"),
payload: jsonb("payload").$type<Record<string, unknown>>(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
resolvedAt: timestamp("resolved_at", { withTimezone: true }),
});
export const qscoreSnapshots = pgTable("qscore_snapshots", {
id: text("id").primaryKey().default(sql`gen_random_uuid()::text`),
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
runId: text("run_id").references(() => workflowRuns.id, { onDelete: "cascade" }),
snapshotType: text("snapshot_type", { enum: ["baseline", "module", "final"] }).notNull(),
score: integer("score"),
payload: jsonb("payload").$type<Record<string, unknown>>(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
});
export const workflowEvents = pgTable("workflow_events", {
id: text("id").primaryKey().default(sql`gen_random_uuid()::text`),
runId: text("run_id").notNull().references(() => workflowRuns.id, { onDelete: "cascade" }),
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
type: text("type").notNull(),
payload: jsonb("payload").$type<Record<string, unknown>>(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
});
export const growConversations = pgTable(
"grow_conversations",
{
id: text("id").primaryKey(),
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
title: text("title").notNull().default("Talk to Me"),
active: boolean("active").notNull().default(true),
metadata: jsonb("metadata").$type<Record<string, unknown>>(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
},
(t) => ({ userIdx: index("grow_conversations_user_idx").on(t.userId, t.updatedAt) }),
);
export const growConversationMessages = pgTable(
"grow_conversation_messages",
{
id: text("id").primaryKey(),
conversationId: text("conversation_id").notNull().references(() => growConversations.id, { onDelete: "cascade" }),
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
role: text("role", { enum: ["user", "assistant"] }).notNull(),
sender: text("sender").notNull(),
content: text("content").notNull(),
metadata: jsonb("metadata").$type<Record<string, unknown>>(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
},
(t) => ({ conversationIdx: index("grow_conversation_messages_conversation_idx").on(t.conversationId, t.createdAt) }),
);
export const growActiveMissions = pgTable(
"grow_active_missions",
{
instanceId: text("instance_id").primaryKey(),
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
missionId: text("mission_id").notNull(),
workflowId: text("workflow_id").notNull(),
actorType: text("actor_type"),
title: text("title").notNull(),
shortTitle: text("short_title").notNull(),
status: text("status").notNull(),
progressPercent: integer("progress_percent").notNull().default(0),
currentStageId: text("current_stage_id"),
goal: text("goal"),
mission: jsonb("mission").$type<Record<string, unknown>>().notNull(),
snapshot: jsonb("snapshot").$type<Record<string, unknown>>(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
},
(t) => ({ userIdx: index("grow_active_missions_user_idx").on(t.userId, t.updatedAt) }),
);
export type OpencodeSessionRow = typeof opencodeSessions.$inferSelect;
export type WorkflowRunRow = typeof workflowRuns.$inferSelect;
export const growEvents = pgTable(
"grow_events",
{
id: text("id").primaryKey().default(sql`gen_random_uuid()::text`),
userId: text("user_id").references(() => users.id, { onDelete: "cascade" }),
orgId: text("org_id"),
source: text("source").notNull(),
type: text("type").notNull(),
category: text("category", {
enum: ["mission", "service", "artifact", "usage", "qscore", "entitlement", "system"],
}).notNull().default("service"),
occurredAt: timestamp("occurred_at", { withTimezone: true }).notNull(),
receivedAt: timestamp("received_at", { withTimezone: true }).defaultNow().notNull(),
mission: jsonb("mission").$type<Record<string, unknown>>(),
subject: jsonb("subject").$type<Record<string, unknown>>(),
correlation: jsonb("correlation").$type<Record<string, unknown>>(),
payload: jsonb("payload").$type<Record<string, unknown>>().notNull().default(sql`'{}'::jsonb`),
raw: jsonb("raw").$type<Record<string, unknown>>(),
dedupeKey: text("dedupe_key"),
processingStatus: text("processing_status", {
enum: ["pending", "processing", "processed", "failed", "unresolved"],
}).notNull().default("pending"),
processingError: text("processing_error"),
processedAt: timestamp("processed_at", { withTimezone: true }),
},
(t) => ({
userIdx: index("grow_events_user_idx").on(t.userId, t.occurredAt),
statusIdx: index("grow_events_status_idx").on(t.processingStatus, t.receivedAt),
sourceIdx: index("grow_events_source_idx").on(t.source, t.type, t.occurredAt),
dedupeIdx: uniqueIndex("grow_events_dedupe_idx").on(t.dedupeKey),
}),
);
export const missionServiceSessions = pgTable(
"mission_service_sessions",
{
id: text("id").primaryKey().default(sql`gen_random_uuid()::text`),
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
missionInstanceId: text("mission_instance_id").references(() => growActiveMissions.instanceId, { onDelete: "set null" }),
missionId: text("mission_id"),
stageId: text("stage_id"),
serviceId: text("service_id").notNull(),
externalId: text("external_id").notNull(),
status: text("status").notNull().default("active"),
metadata: jsonb("metadata").$type<Record<string, unknown>>(),
lastEventId: text("last_event_id").references(() => growEvents.id, { onDelete: "set null" }),
lastCheckedAt: timestamp("last_checked_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
},
(t) => ({
userIdx: index("mission_service_sessions_user_idx").on(t.userId, t.updatedAt),
externalIdx: uniqueIndex("mission_service_sessions_external_idx").on(t.serviceId, t.externalId),
missionIdx: index("mission_service_sessions_mission_idx").on(t.missionInstanceId, t.stageId),
}),
);
export const missionArtifacts = pgTable(
"mission_artifacts",
{
id: text("id").primaryKey().default(sql`gen_random_uuid()::text`),
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
missionInstanceId: text("mission_instance_id").references(() => growActiveMissions.instanceId, { onDelete: "cascade" }),
missionId: text("mission_id"),
stageId: text("stage_id"),
sourceEventId: text("source_event_id").references(() => growEvents.id, { onDelete: "set null" }),
serviceId: text("service_id"),
externalId: text("external_id"),
type: text("type").notNull(),
title: text("title").notNull(),
status: text("status").notNull().default("ready"),
summary: text("summary"),
contentMd: text("content_md"),
metadata: jsonb("metadata").$type<Record<string, unknown>>(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
},
(t) => ({
userIdx: index("mission_artifacts_user_idx").on(t.userId, t.createdAt),
missionIdx: index("mission_artifacts_mission_idx").on(t.missionInstanceId, t.createdAt),
externalIdx: index("mission_artifacts_external_idx").on(t.serviceId, t.externalId),
}),
);
export const growQscoreSignals = pgTable(
"grow_qscore_signals",
{
id: text("id").primaryKey().default(sql`gen_random_uuid()::text`),
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
sourceEventId: text("source_event_id").references(() => growEvents.id, { onDelete: "set null" }),
signalId: text("signal_id").notNull(),
score: doublePrecision("score").notNull(),
present: boolean("present").notNull().default(true),
source: text("source"),
raw: jsonb("raw").$type<Record<string, unknown>>(),
occurredAt: timestamp("occurred_at", { withTimezone: true }).notNull(),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
},
(t) => ({
userIdx: index("grow_qscore_signals_user_idx").on(t.userId, t.occurredAt),
signalIdx: index("grow_qscore_signals_signal_idx").on(t.signalId, t.occurredAt),
}),
);
export const growQscoreLatest = pgTable(
"grow_qscore_latest",
{
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
signalId: text("signal_id").notNull(),
score: doublePrecision("score").notNull(),
present: boolean("present").notNull().default(true),
source: text("source"),
sourceEventId: text("source_event_id").references(() => growEvents.id, { onDelete: "set null" }),
raw: jsonb("raw").$type<Record<string, unknown>>(),
occurredAt: timestamp("occurred_at", { withTimezone: true }).notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
},
(t) => ({
pk: primaryKey({ columns: [t.userId, t.signalId] }),
userIdx: index("grow_qscore_latest_user_idx").on(t.userId, t.updatedAt),
}),
);
export const growQscoreProjectionState = pgTable("grow_qscore_projection_state", {
userId: text("user_id").primaryKey().references(() => users.id, { onDelete: "cascade" }),
score: integer("score").notNull().default(0),
signalCount: integer("signal_count").notNull().default(0),
dimensions: jsonb("dimensions").$type<Record<string, unknown>>(),
summary: text("summary"),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
});
export const missionActions = pgTable(
"mission_actions",
{
id: text("id").primaryKey().default(sql`gen_random_uuid()::text`),
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
missionInstanceId: text("mission_instance_id").notNull().references(() => growActiveMissions.instanceId, { onDelete: "cascade" }),
missionId: text("mission_id").notNull(),
stageId: text("stage_id"),
agentId: text("agent_id").notNull(),
agentName: text("agent_name").notNull(),
baseAgent: text("base_agent"),
serviceId: text("service_id"),
toolName: text("tool_name"),
mode: text("mode", { enum: ["autonomous", "approval_required", "user_input_required", "suggestion"] }).notNull(),
status: text("status", {
enum: ["queued", "running", "waiting_approval", "waiting_user_input", "done", "failed", "dismissed", "snoozed"],
}).notNull().default("queued"),
title: text("title").notNull(),
body: text("body").notNull(),
prompt: text("prompt"),
payload: jsonb("payload").$type<Record<string, unknown>>().notNull().default(sql`'{}'::jsonb`),
result: jsonb("result").$type<Record<string, unknown>>(),
error: text("error"),
sourceEventId: text("source_event_id").references(() => growEvents.id, { onDelete: "set null" }),
idempotencyKey: text("idempotency_key"),
priority: integer("priority").notNull().default(0),
urgency: text("urgency", { enum: ["now", "today", "soon", "calm"] }).notNull().default("calm"),
dueAt: timestamp("due_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
resolvedAt: timestamp("resolved_at", { withTimezone: true }),
},
(t) => ({
missionIdx: index("mission_actions_mission_idx").on(t.userId, t.missionInstanceId, t.status, t.priority),
userIdx: index("mission_actions_user_idx").on(t.userId, t.status, t.updatedAt),
sourceIdx: index("mission_actions_source_idx").on(t.sourceEventId),
dueIdx: index("mission_actions_due_idx").on(t.dueAt),
idempotencyIdx: uniqueIndex("mission_actions_idempotency_idx").on(t.idempotencyKey),
}),
);
export const missionSuggestions = pgTable(
"mission_suggestions",
{
id: text("id").primaryKey().default(sql`gen_random_uuid()::text`),
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
missionInstanceId: text("mission_instance_id").notNull().references(() => growActiveMissions.instanceId, { onDelete: "cascade" }),
missionId: text("mission_id").notNull(),
stageId: text("stage_id"),
role: text("role").notNull(),
type: text("type", { enum: ["action", "practice", "review", "artifact", "blocked", "insight"] }).notNull(),
title: text("title").notNull(),
body: text("body").notNull(),
reason: text("reason"),
priority: integer("priority").notNull().default(0),
urgency: text("urgency", { enum: ["now", "today", "soon", "calm"] }).notNull().default("calm"),
status: text("status", { enum: ["active", "done", "dismissed", "expired"] }).notNull().default("active"),
ctaLabel: text("cta_label").notNull(),
ctaHref: text("cta_href").notNull(),
sourceRefs: jsonb("source_refs").$type<Record<string, unknown>>().notNull().default(sql`'{}'::jsonb`),
generatedBy: text("generated_by", { enum: ["deterministic", "agent", "manual"] }).notNull().default("deterministic"),
expiresAt: timestamp("expires_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
},
(t) => ({
missionIdx: index("mission_suggestions_mission_idx").on(t.userId, t.missionInstanceId, t.status, t.priority),
roleIdx: index("mission_suggestions_role_idx").on(t.missionInstanceId, t.role, t.status),
expiryIdx: index("mission_suggestions_expiry_idx").on(t.expiresAt),
}),
);
export const missionCoachRuns = pgTable(
"mission_coach_runs",
{
id: text("id").primaryKey().default(sql`gen_random_uuid()::text`),
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
missionInstanceId: text("mission_instance_id").notNull().references(() => growActiveMissions.instanceId, { onDelete: "cascade" }),
missionId: text("mission_id").notNull(),
status: text("status", { enum: ["running", "completed", "failed"] }).notNull().default("running"),
windowStart: timestamp("window_start", { withTimezone: true }).notNull(),
windowEnd: timestamp("window_end", { withTimezone: true }).notNull(),
summary: text("summary"),
inputDigest: jsonb("input_digest").$type<Record<string, unknown>>().notNull().default(sql`'{}'::jsonb`),
output: jsonb("output").$type<Record<string, unknown>>().notNull().default(sql`'{}'::jsonb`),
model: text("model"),
promptVersion: text("prompt_version").notNull().default("mission-coach-v1"),
skillVersion: text("skill_version"),
error: text("error"),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
completedAt: timestamp("completed_at", { withTimezone: true }),
},
(t) => ({ missionIdx: index("mission_coach_runs_mission_idx").on(t.userId, t.missionInstanceId, t.createdAt) }),
);
export const growHomeNotifications = pgTable(
"grow_home_notifications",
{
id: text("id").primaryKey().default(sql`gen_random_uuid()::text`),
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
moduleId: text("module_id", {
enum: ["suggestions", "missions", "social", "pathways", "productivity", "rewards"],
}).notNull(),
title: text("title").notNull(),
subtitle: text("subtitle").notNull(),
tag: text("tag").notNull(),
urgency: text("urgency", { enum: ["now", "today", "soon", "calm"] }).notNull().default("calm"),
href: text("href").notNull(),
source: text("source"),
sourceRef: jsonb("source_ref").$type<Record<string, unknown>>(),
priority: integer("priority").notNull().default(0),
generatedBy: text("generated_by", { enum: ["deterministic", "agent", "demo", "manual"] }).notNull().default("deterministic"),
reason: text("reason"),
status: text("status", { enum: ["active", "dismissed", "expired"] }).notNull().default("active"),
expiresAt: timestamp("expires_at", { withTimezone: true }),
createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(),
},
(t) => ({
userIdx: index("grow_home_notifications_user_idx").on(t.userId, t.status, t.priority),
moduleIdx: index("grow_home_notifications_module_idx").on(t.userId, t.moduleId, t.status),
expiryIdx: index("grow_home_notifications_expiry_idx").on(t.expiresAt),
}),
);
export type GrowEventRow = typeof growEvents.$inferSelect;
export type NewGrowEvent = typeof growEvents.$inferInsert;
export type MissionActionRow = typeof missionActions.$inferSelect;
export type NewMissionAction = typeof missionActions.$inferInsert;
export type MissionSuggestionRow = typeof missionSuggestions.$inferSelect;
export type NewMissionSuggestion = typeof missionSuggestions.$inferInsert;
export type MissionCoachRunRow = typeof missionCoachRuns.$inferSelect;
export type GrowHomeNotificationRow = typeof growHomeNotifications.$inferSelect;
export type NewGrowHomeNotification = typeof growHomeNotifications.$inferInsert;