refactor: restructure

monorepo with apps/ and services/
  directories
This commit is contained in:
Wuesteon 2025-11-26 03:03:24 +01:00
parent 25824ed0ac
commit ff80aeec1f
4062 changed files with 2592 additions and 1278 deletions

View file

@ -0,0 +1,33 @@
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import * as schema from './schema';
let connection: ReturnType<typeof postgres> | null = null;
let db: ReturnType<typeof drizzle> | null = null;
export function getConnection(databaseUrl: string) {
if (!connection) {
connection = postgres(databaseUrl, {
max: 10,
idle_timeout: 20,
connect_timeout: 10,
});
}
return connection;
}
export function getDb(databaseUrl: string) {
if (!db) {
const conn = getConnection(databaseUrl);
db = drizzle(conn, { schema });
}
return db;
}
export async function closeConnection() {
if (connection) {
await connection.end();
connection = null;
db = null;
}
}

View file

@ -0,0 +1,29 @@
import { config } from 'dotenv';
import { migrate } from 'drizzle-orm/postgres-js/migrator';
import { getDb, closeConnection } from './connection';
// Load environment variables
config();
async function runMigrations() {
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
throw new Error('DATABASE_URL environment variable is not set');
}
console.log('Running migrations...');
try {
const db = getDb(databaseUrl);
await migrate(db, { migrationsFolder: './src/db/migrations' });
console.log('Migrations completed successfully');
} catch (error) {
console.error('Migration failed:', error);
process.exit(1);
} finally {
await closeConnection();
}
}
runMigrations();

View file

@ -0,0 +1,179 @@
CREATE SCHEMA "auth";
--> statement-breakpoint
CREATE SCHEMA "credits";
--> statement-breakpoint
CREATE TYPE "public"."user_role" AS ENUM('user', 'admin', 'service');--> statement-breakpoint
CREATE TYPE "public"."transaction_status" AS ENUM('pending', 'completed', 'failed', 'cancelled');--> statement-breakpoint
CREATE TYPE "public"."transaction_type" AS ENUM('purchase', 'usage', 'refund', 'bonus', 'expiry', 'adjustment');--> statement-breakpoint
CREATE TABLE "auth"."accounts" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"provider" text NOT NULL,
"provider_account_id" text NOT NULL,
"access_token" text,
"refresh_token" text,
"expires_at" timestamp with time zone,
"token_type" text,
"scope" text,
"id_token" text,
"metadata" jsonb,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "auth"."passwords" (
"user_id" uuid PRIMARY KEY NOT NULL,
"hashed_password" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "auth"."security_events" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid,
"event_type" text NOT NULL,
"ip_address" text,
"user_agent" text,
"metadata" jsonb,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "auth"."sessions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"token" text NOT NULL,
"refresh_token" text NOT NULL,
"refresh_token_expires_at" timestamp with time zone NOT NULL,
"ip_address" text,
"user_agent" text,
"device_id" text,
"device_name" text,
"last_activity_at" timestamp with time zone DEFAULT now() NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"expires_at" timestamp with time zone NOT NULL,
"revoked_at" timestamp with time zone,
CONSTRAINT "sessions_token_unique" UNIQUE("token"),
CONSTRAINT "sessions_refresh_token_unique" UNIQUE("refresh_token")
);
--> statement-breakpoint
CREATE TABLE "auth"."two_factor_auth" (
"user_id" uuid PRIMARY KEY NOT NULL,
"secret" text NOT NULL,
"enabled" boolean DEFAULT false NOT NULL,
"backup_codes" jsonb,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"enabled_at" timestamp with time zone
);
--> statement-breakpoint
CREATE TABLE "auth"."users" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"email" text NOT NULL,
"email_verified" boolean DEFAULT false NOT NULL,
"name" text,
"avatar_url" text,
"role" "user_role" DEFAULT 'user' NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
"deleted_at" timestamp with time zone,
CONSTRAINT "users_email_unique" UNIQUE("email")
);
--> statement-breakpoint
CREATE TABLE "auth"."verification_tokens" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"token" text NOT NULL,
"type" text NOT NULL,
"expires_at" timestamp with time zone NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"used_at" timestamp with time zone,
CONSTRAINT "verification_tokens_token_unique" UNIQUE("token")
);
--> statement-breakpoint
CREATE TABLE "credits"."balances" (
"user_id" uuid PRIMARY KEY NOT NULL,
"balance" integer DEFAULT 0 NOT NULL,
"free_credits_remaining" integer DEFAULT 150 NOT NULL,
"daily_free_credits" integer DEFAULT 5 NOT NULL,
"last_daily_reset_at" timestamp with time zone DEFAULT now(),
"total_earned" integer DEFAULT 0 NOT NULL,
"total_spent" integer DEFAULT 0 NOT NULL,
"version" integer DEFAULT 0 NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "credits"."packages" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" text NOT NULL,
"description" text,
"credits" integer NOT NULL,
"price_euro_cents" integer NOT NULL,
"stripe_price_id" text,
"active" boolean DEFAULT true NOT NULL,
"sort_order" integer DEFAULT 0 NOT NULL,
"metadata" jsonb,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "packages_stripe_price_id_unique" UNIQUE("stripe_price_id")
);
--> statement-breakpoint
CREATE TABLE "credits"."purchases" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"package_id" uuid,
"credits" integer NOT NULL,
"price_euro_cents" integer NOT NULL,
"stripe_payment_intent_id" text,
"stripe_customer_id" text,
"status" "transaction_status" DEFAULT 'pending' NOT NULL,
"metadata" jsonb,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"completed_at" timestamp with time zone,
CONSTRAINT "purchases_stripe_payment_intent_id_unique" UNIQUE("stripe_payment_intent_id")
);
--> statement-breakpoint
CREATE TABLE "credits"."transactions" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"type" "transaction_type" NOT NULL,
"status" "transaction_status" DEFAULT 'pending' NOT NULL,
"amount" integer NOT NULL,
"balance_before" integer NOT NULL,
"balance_after" integer NOT NULL,
"app_id" text NOT NULL,
"description" text NOT NULL,
"metadata" jsonb,
"idempotency_key" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"completed_at" timestamp with time zone,
CONSTRAINT "transactions_idempotency_key_unique" UNIQUE("idempotency_key")
);
--> statement-breakpoint
CREATE TABLE "credits"."usage_stats" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"app_id" text NOT NULL,
"credits_used" integer NOT NULL,
"date" timestamp with time zone NOT NULL,
"metadata" jsonb
);
--> statement-breakpoint
ALTER TABLE "auth"."accounts" ADD CONSTRAINT "accounts_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "auth"."passwords" ADD CONSTRAINT "passwords_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "auth"."security_events" ADD CONSTRAINT "security_events_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "auth"."sessions" ADD CONSTRAINT "sessions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "auth"."two_factor_auth" ADD CONSTRAINT "two_factor_auth_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "auth"."verification_tokens" ADD CONSTRAINT "verification_tokens_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "credits"."balances" ADD CONSTRAINT "balances_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "credits"."purchases" ADD CONSTRAINT "purchases_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "credits"."purchases" ADD CONSTRAINT "purchases_package_id_packages_id_fk" FOREIGN KEY ("package_id") REFERENCES "credits"."packages"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "credits"."transactions" ADD CONSTRAINT "transactions_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "credits"."usage_stats" ADD CONSTRAINT "usage_stats_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "auth"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "purchases_user_id_idx" ON "credits"."purchases" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "purchases_stripe_payment_intent_id_idx" ON "credits"."purchases" USING btree ("stripe_payment_intent_id");--> statement-breakpoint
CREATE INDEX "transactions_user_id_idx" ON "credits"."transactions" USING btree ("user_id");--> statement-breakpoint
CREATE INDEX "transactions_app_id_idx" ON "credits"."transactions" USING btree ("app_id");--> statement-breakpoint
CREATE INDEX "transactions_created_at_idx" ON "credits"."transactions" USING btree ("created_at");--> statement-breakpoint
CREATE INDEX "transactions_idempotency_key_idx" ON "credits"."transactions" USING btree ("idempotency_key");--> statement-breakpoint
CREATE INDEX "usage_stats_user_id_date_idx" ON "credits"."usage_stats" USING btree ("user_id","date");--> statement-breakpoint
CREATE INDEX "usage_stats_app_id_date_idx" ON "credits"."usage_stats" USING btree ("app_id","date");

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1764089133415,
"tag": "0000_lush_ironclad",
"breakpoints": true
}
]
}

View file

@ -0,0 +1,94 @@
import { pgSchema, uuid, text, timestamp, boolean, jsonb, pgEnum } from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
export const authSchema = pgSchema('auth');
// Enum for user roles
export const userRoleEnum = pgEnum('user_role', ['user', 'admin', 'service']);
// Users table
export const users = authSchema.table('users', {
id: uuid('id').primaryKey().defaultRandom(),
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(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
deletedAt: timestamp('deleted_at', { withTimezone: true }),
});
// Sessions table
export const sessions = authSchema.table('sessions', {
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(),
token: text('token').unique().notNull(),
refreshToken: text('refresh_token').unique().notNull(),
refreshTokenExpiresAt: timestamp('refresh_token_expires_at', { withTimezone: true }).notNull(),
ipAddress: text('ip_address'),
userAgent: text('user_agent'),
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(),
revokedAt: timestamp('revoked_at', { withTimezone: true }),
});
// Accounts table (for OAuth providers)
export const accounts = authSchema.table('accounts', {
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('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'),
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 }),
});
// Password table (separate for security)
export const passwords = authSchema.table('passwords', {
userId: uuid('user_id').primaryKey().references(() => users.id, { onDelete: 'cascade' }),
hashedPassword: text('hashed_password').notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
// Two-factor authentication
export const twoFactorAuth = authSchema.table('two_factor_auth', {
userId: uuid('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
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'
ipAddress: text('ip_address'),
userAgent: text('user_agent'),
metadata: jsonb('metadata'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
});

View file

@ -0,0 +1,104 @@
import { pgSchema, uuid, integer, text, timestamp, jsonb, index, pgEnum, boolean } from 'drizzle-orm/pg-core';
import { users } from './auth.schema';
export const creditsSchema = pgSchema('credits');
// Transaction types enum
export const transactionTypeEnum = pgEnum('transaction_type', [
'purchase',
'usage',
'refund',
'bonus',
'expiry',
'adjustment',
]);
// Transaction status enum
export const transactionStatusEnum = pgEnum('transaction_status', [
'pending',
'completed',
'failed',
'cancelled',
]);
// Credit balances (one per user)
export const balances = creditsSchema.table('balances', {
userId: uuid('user_id').primaryKey().references(() => users.id, { onDelete: 'cascade' }),
balance: integer('balance').default(0).notNull(),
freeCreditsRemaining: integer('free_credits_remaining').default(150).notNull(),
dailyFreeCredits: integer('daily_free_credits').default(5).notNull(),
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
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
// Transaction ledger
export const transactions = creditsSchema.table('transactions', {
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(),
type: transactionTypeEnum('type').notNull(),
status: transactionStatusEnum('status').default('pending').notNull(),
amount: integer('amount').notNull(),
balanceBefore: integer('balance_before').notNull(),
balanceAfter: integer('balance_after').notNull(),
appId: text('app_id').notNull(), // 'memoro', 'chat', 'picture', etc.
description: text('description').notNull(),
metadata: jsonb('metadata'), // Additional context
idempotencyKey: text('idempotency_key').unique(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
completedAt: timestamp('completed_at', { withTimezone: true }),
}, (table) => ({
userIdIdx: index('transactions_user_id_idx').on(table.userId),
appIdIdx: index('transactions_app_id_idx').on(table.appId),
createdAtIdx: index('transactions_created_at_idx').on(table.createdAt),
idempotencyKeyIdx: index('transactions_idempotency_key_idx').on(table.idempotencyKey),
}));
// Credit packages (pricing tiers)
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
stripePriceId: text('stripe_price_id').unique(),
active: boolean('active').default(true).notNull(),
sortOrder: integer('sort_order').default(0).notNull(),
metadata: jsonb('metadata'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
// Purchase history
export const purchases = creditsSchema.table('purchases', {
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(),
packageId: uuid('package_id').references(() => packages.id),
credits: integer('credits').notNull(),
priceEuroCents: integer('price_euro_cents').notNull(),
stripePaymentIntentId: text('stripe_payment_intent_id').unique(),
stripeCustomerId: text('stripe_customer_id'),
status: transactionStatusEnum('status').default('pending').notNull(),
metadata: jsonb('metadata'),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
completedAt: timestamp('completed_at', { withTimezone: true }),
}, (table) => ({
userIdIdx: index('purchases_user_id_idx').on(table.userId),
stripePaymentIntentIdIdx: index('purchases_stripe_payment_intent_id_idx').on(table.stripePaymentIntentId),
}));
// Usage tracking (for analytics)
export const usageStats = creditsSchema.table('usage_stats', {
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(),
appId: text('app_id').notNull(),
creditsUsed: integer('credits_used').notNull(),
date: timestamp('date', { withTimezone: true }).notNull(),
metadata: jsonb('metadata'),
}, (table) => ({
userIdDateIdx: index('usage_stats_user_id_date_idx').on(table.userId, table.date),
appIdDateIdx: index('usage_stats_app_id_date_idx').on(table.appId, table.date),
}));

View file

@ -0,0 +1,2 @@
export * from './auth.schema';
export * from './credits.schema';