🔀 merge: auth/complete branch with Better Auth implementation

Merged auth/complete into main with resolved conflicts:
- Kept Better Auth system (EdDSA JWT via JWKS)
- Removed all Coolify references
- Added dev:auth and dev:chat:full scripts for auth development
- Combined zitare scripts from main with auth scripts
- Exported both feedback.schema and organizations.schema

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Wuesteon 2025-12-01 15:25:38 +01:00
commit 8a43bbfc25
84 changed files with 13452 additions and 6778 deletions

View file

@ -1,78 +1,83 @@
import { pgSchema, uuid, text, timestamp, boolean, jsonb, pgEnum } from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
import { pgSchema, uuid, text, timestamp, boolean, jsonb, pgEnum, index } from 'drizzle-orm/pg-core';
export const authSchema = pgSchema('auth');
// Enum for user roles
export const userRoleEnum = pgEnum('user_role', ['user', 'admin', 'service']);
// Users table
// Users table (Better Auth schema)
export const users = authSchema.table('users', {
id: uuid('id').primaryKey().defaultRandom(),
id: text('id').primaryKey(), // Better Auth generates nanoid
name: text('name').notNull(),
email: text('email').unique().notNull(),
emailVerified: boolean('email_verified').default(false).notNull(),
name: text('name'),
avatarUrl: text('avatar_url'),
role: userRoleEnum('role').default('user').notNull(),
image: text('image'), // Better Auth uses 'image' not 'avatarUrl'
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
// Custom fields (not required by Better Auth)
role: userRoleEnum('role').default('user').notNull(),
deletedAt: timestamp('deleted_at', { withTimezone: true }),
});
// Sessions table
// Sessions table (Better Auth schema)
export const sessions = authSchema.table('sessions', {
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id')
.references(() => users.id, { onDelete: 'cascade' })
.notNull(),
id: text('id').primaryKey(), // Better Auth generates nanoid
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
token: text('token').unique().notNull(),
refreshToken: text('refresh_token').unique().notNull(),
refreshTokenExpiresAt: timestamp('refresh_token_expires_at', { withTimezone: true }).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
ipAddress: text('ip_address'),
userAgent: text('user_agent'),
userId: text('user_id')
.references(() => users.id, { onDelete: 'cascade' })
.notNull(),
// Custom fields (not required by Better Auth)
refreshToken: text('refresh_token').unique(),
refreshTokenExpiresAt: timestamp('refresh_token_expires_at', { withTimezone: true }),
deviceId: text('device_id'),
deviceName: text('device_name'),
lastActivityAt: timestamp('last_activity_at', { withTimezone: true }).defaultNow().notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
lastActivityAt: timestamp('last_activity_at', { withTimezone: true }).defaultNow(),
revokedAt: timestamp('revoked_at', { withTimezone: true }),
});
// Accounts table (for OAuth providers)
// Accounts table (for OAuth providers and credentials - Better Auth schema)
export const accounts = authSchema.table('accounts', {
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id')
id: text('id').primaryKey(), // Better Auth generates nanoid
accountId: text('account_id').notNull(), // Better Auth field
providerId: text('provider_id').notNull(), // Better Auth field (was 'provider')
userId: text('user_id')
.references(() => users.id, { onDelete: 'cascade' })
.notNull(),
provider: text('provider').notNull(), // 'google', 'github', 'apple', etc.
providerAccountId: text('provider_account_id').notNull(),
accessToken: text('access_token'),
refreshToken: text('refresh_token'),
expiresAt: timestamp('expires_at', { withTimezone: true }),
tokenType: text('token_type'),
scope: text('scope'),
idToken: text('id_token'),
metadata: jsonb('metadata'),
accessTokenExpiresAt: timestamp('access_token_expires_at', { withTimezone: true }),
refreshTokenExpiresAt: timestamp('refresh_token_expires_at', { withTimezone: true }),
scope: text('scope'),
password: text('password'), // Better Auth stores hashed password here for credential provider
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
// Verification tokens (for email verification, password reset)
export const verificationTokens = authSchema.table('verification_tokens', {
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id')
.references(() => users.id, { onDelete: 'cascade' })
.notNull(),
token: text('token').unique().notNull(),
type: text('type').notNull(), // 'email_verification', 'password_reset'
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
usedAt: timestamp('used_at', { withTimezone: true }),
});
// Verification table (Better Auth schema - for email verification, password reset)
export const verificationTokens = authSchema.table(
'verification',
{
id: text('id').primaryKey(), // Better Auth generates nanoid
identifier: text('identifier').notNull(), // Better Auth uses identifier (e.g., email)
value: text('value').notNull(), // Better Auth uses value (the token)
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
},
(table) => ({
identifierIdx: index('verification_identifier_idx').on(table.identifier),
})
);
// Password table (separate for security)
export const passwords = authSchema.table('passwords', {
userId: uuid('user_id')
userId: text('user_id')
.primaryKey()
.references(() => users.id, { onDelete: 'cascade' }),
hashedPassword: text('hashed_password').notNull(),
@ -82,23 +87,31 @@ export const passwords = authSchema.table('passwords', {
// Two-factor authentication
export const twoFactorAuth = authSchema.table('two_factor_auth', {
userId: uuid('user_id')
userId: text('user_id')
.primaryKey()
.references(() => users.id, { onDelete: 'cascade' }),
secret: text('secret').notNull(),
enabled: boolean('enabled').default(false).notNull(),
backupCodes: jsonb('backup_codes'), // Array of hashed backup codes
backupCodes: jsonb('backup_codes'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
enabledAt: timestamp('enabled_at', { withTimezone: true }),
});
// Security events log
export const securityEvents = authSchema.table('security_events', {
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }),
eventType: text('event_type').notNull(), // 'login', 'logout', 'password_reset', 'suspicious_activity'
id: uuid('id').primaryKey().defaultRandom(), // Our table, can keep UUID
userId: text('user_id').references(() => users.id, { onDelete: 'cascade' }),
eventType: text('event_type').notNull(),
ipAddress: text('ip_address'),
userAgent: text('user_agent'),
metadata: jsonb('metadata'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
});
// JWKS table (Better Auth JWT plugin - stores signing keys)
export const jwks = authSchema.table('jwks', {
id: text('id').primaryKey(),
publicKey: text('public_key').notNull(),
privateKey: text('private_key').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
});

View file

@ -10,6 +10,7 @@ import {
boolean,
} from 'drizzle-orm/pg-core';
import { users } from './auth.schema';
import { organizations } from './organizations.schema';
export const creditsSchema = pgSchema('credits');
@ -33,7 +34,7 @@ export const transactionStatusEnum = pgEnum('transaction_status', [
// Credit balances (one per user)
export const balances = creditsSchema.table('balances', {
userId: uuid('user_id')
userId: text('user_id')
.primaryKey()
.references(() => users.id, { onDelete: 'cascade' }),
balance: integer('balance').default(0).notNull(),
@ -42,7 +43,7 @@ export const balances = creditsSchema.table('balances', {
lastDailyResetAt: timestamp('last_daily_reset_at', { withTimezone: true }).defaultNow(),
totalEarned: integer('total_earned').default(0).notNull(),
totalSpent: integer('total_spent').default(0).notNull(),
version: integer('version').default(0).notNull(), // For optimistic locking
version: integer('version').default(0).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
@ -52,7 +53,7 @@ export const transactions = creditsSchema.table(
'transactions',
{
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id')
userId: text('user_id')
.references(() => users.id, { onDelete: 'cascade' })
.notNull(),
type: transactionTypeEnum('type').notNull(),
@ -60,9 +61,10 @@ export const transactions = creditsSchema.table(
amount: integer('amount').notNull(),
balanceBefore: integer('balance_before').notNull(),
balanceAfter: integer('balance_after').notNull(),
appId: text('app_id').notNull(), // 'memoro', 'chat', 'picture', etc.
appId: text('app_id').notNull(),
description: text('description').notNull(),
metadata: jsonb('metadata'), // Additional context
organizationId: text('organization_id').references(() => organizations.id),
metadata: jsonb('metadata'),
idempotencyKey: text('idempotency_key').unique(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
completedAt: timestamp('completed_at', { withTimezone: true }),
@ -70,6 +72,7 @@ export const transactions = creditsSchema.table(
(table) => ({
userIdIdx: index('transactions_user_id_idx').on(table.userId),
appIdIdx: index('transactions_app_id_idx').on(table.appId),
organizationIdIdx: index('transactions_organization_id_idx').on(table.organizationId),
createdAtIdx: index('transactions_created_at_idx').on(table.createdAt),
idempotencyKeyIdx: index('transactions_idempotency_key_idx').on(table.idempotencyKey),
})
@ -80,8 +83,8 @@ export const packages = creditsSchema.table('packages', {
id: uuid('id').primaryKey().defaultRandom(),
name: text('name').notNull(),
description: text('description'),
credits: integer('credits').notNull(), // Number of credits
priceEuroCents: integer('price_euro_cents').notNull(), // Price in euro cents
credits: integer('credits').notNull(),
priceEuroCents: integer('price_euro_cents').notNull(),
stripePriceId: text('stripe_price_id').unique(),
active: boolean('active').default(true).notNull(),
sortOrder: integer('sort_order').default(0).notNull(),
@ -95,7 +98,7 @@ export const purchases = creditsSchema.table(
'purchases',
{
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id')
userId: text('user_id')
.references(() => users.id, { onDelete: 'cascade' })
.notNull(),
packageId: uuid('package_id').references(() => packages.id),
@ -121,7 +124,7 @@ export const usageStats = creditsSchema.table(
'usage_stats',
{
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id')
userId: text('user_id')
.references(() => users.id, { onDelete: 'cascade' })
.notNull(),
appId: text('app_id').notNull(),
@ -134,3 +137,47 @@ export const usageStats = creditsSchema.table(
appIdDateIdx: index('usage_stats_app_id_date_idx').on(table.appId, table.date),
})
);
// Organization credit balances (B2B)
export const organizationBalances = creditsSchema.table('organization_balances', {
organizationId: text('organization_id')
.primaryKey()
.references(() => organizations.id, { onDelete: 'cascade' }),
balance: integer('balance').default(0).notNull(),
allocatedCredits: integer('allocated_credits').default(0).notNull(),
availableCredits: integer('available_credits').default(0).notNull(),
totalPurchased: integer('total_purchased').default(0).notNull(),
totalAllocated: integer('total_allocated').default(0).notNull(),
version: integer('version').default(0).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
// Credit allocations (B2B - tracking allocations from org to employees)
export const creditAllocations = creditsSchema.table(
'credit_allocations',
{
id: uuid('id').primaryKey().defaultRandom(),
organizationId: text('organization_id')
.references(() => organizations.id, { onDelete: 'cascade' })
.notNull(),
employeeId: text('employee_id')
.references(() => users.id, { onDelete: 'cascade' })
.notNull(),
amount: integer('amount').notNull(),
allocatedBy: text('allocated_by')
.references(() => users.id)
.notNull(),
reason: text('reason'),
balanceBefore: integer('balance_before').notNull(),
balanceAfter: integer('balance_after').notNull(),
metadata: jsonb('metadata'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
},
(table) => ({
organizationIdIdx: index('credit_allocations_organization_id_idx').on(table.organizationId),
employeeIdIdx: index('credit_allocations_employee_id_idx').on(table.employeeId),
allocatedByIdx: index('credit_allocations_allocated_by_idx').on(table.allocatedBy),
createdAtIdx: index('credit_allocations_created_at_idx').on(table.createdAt),
})
);

View file

@ -1,3 +1,4 @@
export * from './auth.schema';
export * from './credits.schema';
export * from './feedback.schema';
export * from './organizations.schema';

View file

@ -0,0 +1,72 @@
import { pgSchema, text, timestamp, jsonb, index } from 'drizzle-orm/pg-core';
import { authSchema, users } from './auth.schema';
/**
* Better Auth Organization Tables
* These tables follow Better Auth's organization plugin schema requirements
* @see https://www.better-auth.com/docs/plugins/organization
*
* Note: Better Auth uses TEXT for IDs (nanoid/ULID), but we use UUID for users.
* The foreign key constraints will be added via raw SQL migration to handle the type difference.
*/
// Organizations table
export const organizations = authSchema.table(
'organizations',
{
id: text('id').primaryKey(), // Better Auth uses TEXT IDs (ULIDs/nanoids)
name: text('name').notNull(),
slug: text('slug').unique(),
logo: text('logo'),
metadata: jsonb('metadata'), // Additional organization data
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
},
(table) => ({
slugIdx: index('organizations_slug_idx').on(table.slug),
})
);
// Members table (links users to organizations with roles)
export const members = authSchema.table(
'members',
{
id: text('id').primaryKey(), // Better Auth uses TEXT IDs
organizationId: text('organization_id')
.references(() => organizations.id, { onDelete: 'cascade' })
.notNull(),
userId: text('user_id').notNull(), // References auth.users.id (UUID cast to TEXT)
role: text('role').notNull(), // 'owner', 'admin', 'member', or custom roles
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
},
(table) => ({
organizationIdIdx: index('members_organization_id_idx').on(table.organizationId),
userIdIdx: index('members_user_id_idx').on(table.userId),
organizationUserIdx: index('members_organization_user_idx').on(
table.organizationId,
table.userId
),
})
);
// Invitations table (for inviting users to organizations)
export const invitations = authSchema.table(
'invitations',
{
id: text('id').primaryKey(), // Better Auth uses TEXT IDs
organizationId: text('organization_id')
.references(() => organizations.id, { onDelete: 'cascade' })
.notNull(),
email: text('email').notNull(),
role: text('role').notNull(), // Role they'll have when they accept
status: text('status').notNull(), // 'pending', 'accepted', 'rejected', 'canceled'
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
inviterId: text('inviter_id'), // References auth.users.id (UUID cast to TEXT)
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
},
(table) => ({
organizationIdIdx: index('invitations_organization_id_idx').on(table.organizationId),
emailIdx: index('invitations_email_idx').on(table.email),
statusIdx: index('invitations_status_idx').on(table.status),
})
);