src/db/schema.ts
Rama main · réplica de lectura del repositorio original.
import { API_KEY_TYPES, DEFAULT_USER_ROLE } from '../auth/constants'
import { relations, sql } from 'drizzle-orm'
import {
boolean,
check,
integer,
jsonb,
pgTable,
text,
timestamp,
unique,
uuid,
} from 'drizzle-orm/pg-core'
const id = () => uuid('id').primaryKey().defaultRandom()
const createdAt = () => timestamp('created_at', { withTimezone: true }).notNull().defaultNow()
const updatedAt = () => timestamp('updated_at', { withTimezone: true }).notNull().defaultNow()
/** Default state a new flag starts in across environments. */
export type DefaultFlagState = 'off' | 'dev' | 'on'
/**
* Project-level settings bag. Stored as jsonb so new setting groups (e.g.
* security) can be added without a migration each time. All fields optional;
* readers fall back to sensible defaults.
*/
export interface ProjectSettings {
flagDefaults?: {
/** Initial state applied to a new flag's environments. */
defaultState?: DefaultFlagState
/** Days without a value change before a flag is considered stale; null = never. */
staleFlagDays?: number | null
/** Require a description before a flag can be created. */
requireDescription?: boolean
}
security?: {
/**
* Requires a second, distinct owner/admin (or admin API key) to repeat
* a toggle in a protected environment before it actually applies.
*/
requireApprovalInProd?: boolean
/** Max lifetime for newly-issued admin keys, in days; null/undefined = no expiry. */
keyTtlDays?: number | null
}
}
export const projects = pgTable('projects', {
id: id(),
name: text('name').notNull(),
slug: text('slug').notNull().unique(),
description: text('description'),
settings: jsonb('settings').$type<ProjectSettings>().notNull().default({}),
createdAt: createdAt(),
updatedAt: updatedAt(),
})
export const environments = pgTable(
'environments',
{
id: id(),
projectId: uuid('project_id')
.notNull()
.references(() => projects.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
slug: text('slug').notNull(),
protected: boolean('protected').notNull().default(false),
createdAt: createdAt(),
},
(table) => [unique('environments_project_id_slug_unique').on(table.projectId, table.slug)],
)
export const featureFlags = pgTable(
'feature_flags',
{
id: id(),
projectId: uuid('project_id')
.notNull()
.references(() => projects.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
key: text('key').notNull(),
description: text('description'),
authorId: uuid('author_id').references(() => users.id, { onDelete: 'set null' }),
createdAt: createdAt(),
updatedAt: updatedAt(),
},
(table) => [unique('feature_flags_project_id_key_unique').on(table.projectId, table.key)],
)
export const flagEnvironments = pgTable(
'flag_environments',
{
id: id(),
flagId: uuid('flag_id')
.notNull()
.references(() => featureFlags.id, { onDelete: 'cascade' }),
environmentId: uuid('environment_id')
.notNull()
.references(() => environments.id, { onDelete: 'cascade' }),
enabled: boolean('enabled').notNull().default(false),
updatedAt: updatedAt(),
},
(table) => [
unique('flag_environments_flag_id_environment_id_unique').on(table.flagId, table.environmentId),
],
)
export const apiKeys = pgTable(
'api_keys',
{
id: id(),
projectId: uuid('project_id').references(() => projects.id, { onDelete: 'cascade' }),
environmentId: uuid('environment_id').references(() => environments.id, {
onDelete: 'cascade',
}),
keyHash: text('key_hash').notNull().unique(),
keyPrefix: text('key_prefix').notNull(),
type: text('type').notNull(),
description: text('description'),
createdAt: createdAt(),
lastUsedAt: timestamp('last_used_at', { withTimezone: true }),
/** Admin keys only; null means no expiry. Enforced at auth time. */
expiresAt: timestamp('expires_at', { withTimezone: true }),
},
() => [
check(
'api_keys_type_check',
sql.raw(`"type" IN ('${API_KEY_TYPES.CLIENT}', '${API_KEY_TYPES.ADMIN}')`),
),
],
)
export const users = pgTable('users', {
id: id(),
email: text('email').notNull().unique(),
passwordHash: text('password_hash').notNull(),
name: text('name').notNull(),
role: text('role').notNull().default(DEFAULT_USER_ROLE),
status: text('status').notNull().default('active'),
twoFa: text('two_fa').notNull().default('none'),
isSystem: boolean('is_system').notNull().default(false),
initials: text('initials').notNull().default(''),
tone: text('tone').notNull().default('teal'),
/** Stamped on every successful login; null until the user logs in once. */
lastLoginAt: timestamp('last_login_at', { withTimezone: true }),
/**
* Bumped whenever existing sessions must be invalidated (e.g. password
* reset). Session JWTs carry this value and are rejected on mismatch.
*/
sessionVersion: integer('session_version').notNull().default(0),
/** SHA-256 of the pending invite token; null once the invite is accepted or never issued. */
inviteTokenHash: text('invite_token_hash'),
/** When the pending invite link stops working. */
inviteExpiresAt: timestamp('invite_expires_at', { withTimezone: true }),
createdAt: createdAt(),
updatedAt: updatedAt(),
})
export const userProjects = pgTable(
'user_projects',
{
id: id(),
userId: uuid('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
projectId: uuid('project_id')
.notNull()
.references(() => projects.id, { onDelete: 'cascade' }),
createdAt: createdAt(),
},
(table) => [unique('user_projects_user_id_project_id_unique').on(table.userId, table.projectId)],
)
export const contextFields = pgTable(
'context_fields',
{
id: id(),
projectId: uuid('project_id')
.notNull()
.references(() => projects.id, { onDelete: 'cascade' }),
key: text('key').notNull(),
type: text('type').notNull().default('string'),
description: text('description'),
enumValues: text('enum_values').array(),
createdAt: createdAt(),
updatedAt: updatedAt(),
},
(table) => [
unique('context_fields_project_id_key_unique').on(table.projectId, table.key),
check(
'context_fields_type_check',
sql`"type" IN ('string','enum','boolean','number','version','date')`,
),
],
)
/**
* One condition inside a strategy. All constraints in a strategy must pass
* (AND). `values` holds one entry for single-value operators and many for
* list operators (in / notIn).
*/
export interface StrategyConstraint {
fieldKey: string
operator: string
values: string[]
}
/**
* One flag+environment awaiting a second, distinct admin to confirm a
* toggle, when the project requires approval in protected environments.
* At most one row per (flagId, environmentId): a repeat request from the
* original requester is a no-op, a different desired state replaces it, and
* a different requester repeating the same desired state is the
* confirmation -- see requestOrConfirmToggle in flag.service.ts.
*/
export const pendingFlagToggles = pgTable(
'pending_flag_toggles',
{
id: id(),
projectId: uuid('project_id')
.notNull()
.references(() => projects.id, { onDelete: 'cascade' }),
flagId: uuid('flag_id')
.notNull()
.references(() => featureFlags.id, { onDelete: 'cascade' }),
environmentId: uuid('environment_id')
.notNull()
.references(() => environments.id, { onDelete: 'cascade' }),
requestedEnabled: boolean('requested_enabled').notNull(),
/** `user:<id>` for a session, `key:<id>` for an API key -- identifies who requested. */
requestedByIdentity: text('requested_by_identity').notNull(),
/** Display name snapshotted at request time, for the pending badge. */
requestedByLabel: text('requested_by_label').notNull(),
createdAt: createdAt(),
},
(table) => [
unique('pending_flag_toggles_flag_id_environment_id_unique').on(
table.flagId,
table.environmentId,
),
],
)
export const targetingStrategies = pgTable('targeting_strategies', {
id: id(),
flagId: uuid('flag_id')
.notNull()
.references(() => featureFlags.id, { onDelete: 'cascade' }),
environmentId: uuid('environment_id')
.notNull()
.references(() => environments.id, { onDelete: 'cascade' }),
/** Stable display order within a flag+environment. */
position: integer('position').notNull().default(0),
constraints: jsonb('constraints').$type<StrategyConstraint[]>().notNull().default([]),
createdAt: createdAt(),
updatedAt: updatedAt(),
})
export const projectRelations = relations(projects, ({ many }) => ({
environments: many(environments),
flags: many(featureFlags),
apiKeys: many(apiKeys),
members: many(userProjects),
contextFields: many(contextFields),
pendingFlagToggles: many(pendingFlagToggles),
}))
export const pendingFlagToggleRelations = relations(pendingFlagToggles, ({ one }) => ({
project: one(projects, { fields: [pendingFlagToggles.projectId], references: [projects.id] }),
flag: one(featureFlags, {
fields: [pendingFlagToggles.flagId],
references: [featureFlags.id],
}),
environment: one(environments, {
fields: [pendingFlagToggles.environmentId],
references: [environments.id],
}),
}))
export const contextFieldRelations = relations(contextFields, ({ one }) => ({
project: one(projects, { fields: [contextFields.projectId], references: [projects.id] }),
}))
export const targetingStrategyRelations = relations(targetingStrategies, ({ one }) => ({
flag: one(featureFlags, {
fields: [targetingStrategies.flagId],
references: [featureFlags.id],
}),
environment: one(environments, {
fields: [targetingStrategies.environmentId],
references: [environments.id],
}),
}))
export const environmentRelations = relations(environments, ({ one, many }) => ({
project: one(projects, { fields: [environments.projectId], references: [projects.id] }),
flagEnvironments: many(flagEnvironments),
apiKeys: many(apiKeys),
strategies: many(targetingStrategies),
}))
export const featureFlagRelations = relations(featureFlags, ({ one, many }) => ({
project: one(projects, { fields: [featureFlags.projectId], references: [projects.id] }),
environments: many(flagEnvironments),
strategies: many(targetingStrategies),
}))
export const flagEnvironmentsRelations = relations(flagEnvironments, ({ one }) => ({
flag: one(featureFlags, { fields: [flagEnvironments.flagId], references: [featureFlags.id] }),
environment: one(environments, {
fields: [flagEnvironments.environmentId],
references: [environments.id],
}),
}))
export const apiKeysRelations = relations(apiKeys, ({ one }) => ({
project: one(projects, { fields: [apiKeys.projectId], references: [projects.id] }),
environment: one(environments, {
fields: [apiKeys.environmentId],
references: [environments.id],
}),
}))
export const userRelations = relations(users, ({ many }) => ({
projects: many(userProjects),
}))
export const userProjectRelations = relations(userProjects, ({ one }) => ({
user: one(users, { fields: [userProjects.userId], references: [users.id] }),
project: one(projects, { fields: [userProjects.projectId], references: [projects.id] }),
}))
export type Project = typeof projects.$inferSelect
export type NewProject = typeof projects.$inferInsert
export type Environment = typeof environments.$inferSelect
export type NewEnvironment = typeof environments.$inferInsert
export type FeatureFlag = typeof featureFlags.$inferSelect
export type NewFeatureFlag = typeof featureFlags.$inferInsert
export type FlagEnvironment = typeof flagEnvironments.$inferSelect
export type NewFlagEnvironment = typeof flagEnvironments.$inferInsert
export type ApiKey = typeof apiKeys.$inferSelect
export type NewApiKey = typeof apiKeys.$inferInsert
export type User = typeof users.$inferSelect
export type NewUser = typeof users.$inferInsert
export type UserProject = typeof userProjects.$inferSelect
export type ContextField = typeof contextFields.$inferSelect
export type NewContextField = typeof contextFields.$inferInsert
export type TargetingStrategy = typeof targetingStrategies.$inferSelect
export type NewTargetingStrategy = typeof targetingStrategies.$inferInsert
export type PendingFlagToggle = typeof pendingFlagToggles.$inferSelect
export type NewPendingFlagToggle = typeof pendingFlagToggles.$inferInsert