project init

This commit is contained in:
2026-02-06 02:17:59 +03:00
commit b9d9b9ed17
129 changed files with 22835 additions and 0 deletions

View File

@ -0,0 +1,27 @@
{
"name": "@coursecraft/database",
"version": "0.1.0",
"private": true,
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"scripts": {
"build": "tsc",
"clean": "rm -rf dist .turbo",
"dev": "tsc --watch",
"db:generate": "prisma generate",
"db:push": "prisma db push",
"db:migrate": "prisma migrate dev",
"db:migrate:deploy": "prisma migrate deploy",
"db:seed": "ts-node prisma/seed.ts",
"studio": "prisma studio"
},
"dependencies": {
"@prisma/client": "^5.10.0"
},
"devDependencies": {
"@types/node": "^20.11.0",
"prisma": "^5.10.0",
"ts-node": "^10.9.2",
"typescript": "^5.4.0"
}
}

View File

@ -0,0 +1,362 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
previewFeatures = ["postgresqlExtensions"]
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
extensions = [pgvector(map: "vector"), uuidOssp(map: "uuid-ossp")]
}
// ============================================
// User & Authentication
// ============================================
model User {
id String @id @default(uuid())
supabaseId String @unique @map("supabase_id")
email String @unique
name String?
avatarUrl String? @map("avatar_url")
// Subscription
subscriptionTier SubscriptionTier @default(FREE) @map("subscription_tier")
// Timestamps
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relations
settings UserSettings?
subscription Subscription?
courses Course[] @relation("AuthoredCourses")
purchases Purchase[]
reviews Review[]
generations CourseGeneration[]
@@map("users")
}
model UserSettings {
id String @id @default(uuid())
userId String @unique @map("user_id")
// AI Settings - user can override default model
customAiModel String? @map("custom_ai_model") // e.g., "qwen/qwen3-coder-next"
// Notification settings
emailNotifications Boolean @default(true) @map("email_notifications")
marketingEmails Boolean @default(false) @map("marketing_emails")
// UI Preferences
theme String @default("system") // "light", "dark", "system"
language String @default("ru")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relations
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("user_settings")
}
// ============================================
// Subscription & Payments
// ============================================
enum SubscriptionTier {
FREE
PREMIUM
PRO
}
model Subscription {
id String @id @default(uuid())
userId String @unique @map("user_id")
tier SubscriptionTier @default(FREE)
// Stripe
stripeCustomerId String? @unique @map("stripe_customer_id")
stripeSubscriptionId String? @unique @map("stripe_subscription_id")
stripePriceId String? @map("stripe_price_id")
// Billing cycle
currentPeriodStart DateTime? @map("current_period_start")
currentPeriodEnd DateTime? @map("current_period_end")
cancelAtPeriodEnd Boolean @default(false) @map("cancel_at_period_end")
// Usage tracking
coursesCreatedThisMonth Int @default(0) @map("courses_created_this_month")
usageResetDate DateTime? @map("usage_reset_date")
// Status
status String @default("active") // active, canceled, past_due, incomplete
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relations
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@map("subscriptions")
}
// ============================================
// Courses
// ============================================
enum CourseStatus {
DRAFT
GENERATING
PUBLISHED
ARCHIVED
}
model Course {
id String @id @default(uuid())
authorId String @map("author_id")
// Basic info
title String
description String? @db.Text
slug String @unique
coverImage String? @map("cover_image")
// Status
status CourseStatus @default(DRAFT)
// Marketplace (future)
isPublished Boolean @default(false) @map("is_published")
price Decimal? @db.Decimal(10, 2) // null = private course
currency String @default("USD")
// Categorization
categoryId String? @map("category_id")
tags String[] @default([])
difficulty String? // "beginner", "intermediate", "advanced"
estimatedHours Int? @map("estimated_hours")
// SEO & metadata
metaTitle String? @map("meta_title")
metaDescription String? @map("meta_description")
// Stats
viewCount Int @default(0) @map("view_count")
enrollmentCount Int @default(0) @map("enrollment_count")
averageRating Float? @map("average_rating")
// Timestamps
publishedAt DateTime? @map("published_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relations
author User @relation("AuthoredCourses", fields: [authorId], references: [id], onDelete: Cascade)
category Category? @relation(fields: [categoryId], references: [id])
chapters Chapter[]
purchases Purchase[]
reviews Review[]
generation CourseGeneration?
// Vector embedding for semantic search
embedding Unsupported("vector(1536)")?
@@index([authorId])
@@index([status])
@@index([isPublished])
@@index([categoryId])
@@map("courses")
}
model Chapter {
id String @id @default(uuid())
courseId String @map("course_id")
title String
description String? @db.Text
order Int @default(0)
// Timestamps
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relations
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
lessons Lesson[]
@@index([courseId])
@@map("chapters")
}
model Lesson {
id String @id @default(uuid())
chapterId String @map("chapter_id")
title String
content Json? // TipTap JSON content
order Int @default(0)
// Duration estimate
durationMinutes Int? @map("duration_minutes")
// Media
videoUrl String? @map("video_url")
// Timestamps
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relations
chapter Chapter @relation(fields: [chapterId], references: [id], onDelete: Cascade)
// Vector embedding for semantic search
embedding Unsupported("vector(1536)")?
@@index([chapterId])
@@map("lessons")
}
// ============================================
// AI Course Generation
// ============================================
enum GenerationStatus {
PENDING
ANALYZING
ASKING_QUESTIONS
WAITING_FOR_ANSWERS
RESEARCHING
GENERATING_OUTLINE
GENERATING_CONTENT
COMPLETED
FAILED
CANCELLED
}
model CourseGeneration {
id String @id @default(uuid())
userId String @map("user_id")
courseId String? @unique @map("course_id")
// Input
initialPrompt String @db.Text @map("initial_prompt")
// AI Configuration
aiModel String @map("ai_model")
// Status & Progress
status GenerationStatus @default(PENDING)
progress Int @default(0) // 0-100
currentStep String? @map("current_step")
// Clarifying questions flow
questions Json? // Array of questions to ask
answers Json? // User's answers
// Generated outline (intermediate state)
generatedOutline Json? @map("generated_outline")
// Error handling
errorMessage String? @db.Text @map("error_message")
retryCount Int @default(0) @map("retry_count")
// Job tracking
jobId String? @map("job_id") // BullMQ job ID
// Timestamps
startedAt DateTime? @map("started_at")
completedAt DateTime? @map("completed_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relations
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
course Course? @relation(fields: [courseId], references: [id], onDelete: SetNull)
@@index([userId])
@@index([status])
@@map("course_generations")
}
// ============================================
// Marketplace (Future)
// ============================================
model Category {
id String @id @default(uuid())
name String @unique
slug String @unique
description String? @db.Text
icon String?
parentId String? @map("parent_id")
order Int @default(0)
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relations
parent Category? @relation("CategoryHierarchy", fields: [parentId], references: [id])
children Category[] @relation("CategoryHierarchy")
courses Course[]
@@map("categories")
}
model Purchase {
id String @id @default(uuid())
userId String @map("user_id")
courseId String @map("course_id")
// Payment details
amount Decimal @db.Decimal(10, 2)
currency String @default("USD")
stripePaymentId String? @map("stripe_payment_id")
// Status
status String @default("completed") // pending, completed, refunded
// Access
accessGrantedAt DateTime @default(now()) @map("access_granted_at")
accessExpiresAt DateTime? @map("access_expires_at") // null = lifetime access
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relations
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
@@unique([userId, courseId])
@@index([userId])
@@index([courseId])
@@map("purchases")
}
model Review {
id String @id @default(uuid())
userId String @map("user_id")
courseId String @map("course_id")
rating Int // 1-5
title String?
content String? @db.Text
// Moderation
isApproved Boolean @default(true) @map("is_approved")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
// Relations
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
course Course @relation(fields: [courseId], references: [id], onDelete: Cascade)
@@unique([userId, courseId])
@@index([courseId])
@@map("reviews")
}

View File

@ -0,0 +1,36 @@
import { PrismaClient } from '@prisma/client';
// Export Prisma Client
export * from '@prisma/client';
// Singleton pattern for Prisma Client
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined;
};
export const prisma =
globalForPrisma.prisma ??
new PrismaClient({
log: process.env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
});
if (process.env.NODE_ENV !== 'production') {
globalForPrisma.prisma = prisma;
}
// Type exports for convenience
export type {
User,
UserSettings,
Subscription,
Course,
Chapter,
Lesson,
CourseGeneration,
Category,
Purchase,
Review,
} from '@prisma/client';
// Enum re-exports
export { SubscriptionTier, CourseStatus, GenerationStatus } from '@prisma/client';

View File

@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

View File

@ -0,0 +1,16 @@
{
"name": "@coursecraft/shared",
"version": "0.1.0",
"private": true,
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"scripts": {
"build": "tsc",
"clean": "rm -rf dist .turbo",
"dev": "tsc --watch"
},
"devDependencies": {
"@types/node": "^20.11.0",
"typescript": "^5.4.0"
}
}

View File

@ -0,0 +1,204 @@
// Default AI model for all tiers (can override in user settings on PRO)
const DEFAULT_AI_MODEL = 'openai/gpt-4o-mini';
// Subscription tier limits
export const SUBSCRIPTION_LIMITS = {
FREE: {
coursesPerMonth: 2,
defaultAiModel: DEFAULT_AI_MODEL,
},
PREMIUM: {
coursesPerMonth: 5,
defaultAiModel: DEFAULT_AI_MODEL,
},
PRO: {
coursesPerMonth: 15,
defaultAiModel: DEFAULT_AI_MODEL,
},
} as const;
// Prices in RUB for Russian locale (approximate)
const PRICE_RUB = {
FREE: 0,
PREMIUM: 999,
PRO: 2999,
} as const;
// Subscription plans for display
export const SUBSCRIPTION_PLANS = [
{
tier: 'FREE' as const,
name: 'Free',
nameRu: 'Бесплатный',
description: 'Perfect for trying out CourseCraft',
descriptionRu: 'Идеально для знакомства с CourseCraft',
price: 0,
priceRu: PRICE_RUB.FREE,
currency: 'USD',
features: [
'2 courses per month',
'Basic AI model',
'Standard support',
],
featuresRu: [
'2 курса в месяц',
'Базовая нейросеть',
'Стандартная поддержка',
],
stripePriceId: null,
},
{
tier: 'PREMIUM' as const,
name: 'Premium',
nameRu: 'Премиум',
description: 'For creators who need more',
descriptionRu: 'Для тех, кому нужно больше',
price: 9.99,
priceRu: PRICE_RUB.PREMIUM,
currency: 'USD',
features: [
'5 courses per month',
'Enhanced AI model',
'Priority support',
'Advanced editing tools',
],
featuresRu: [
'5 курсов в месяц',
'Улучшенная нейросеть',
'Приоритетная поддержка',
'Расширенные инструменты редактирования',
],
stripePriceId: process.env.STRIPE_PRICE_PREMIUM || null,
},
{
tier: 'PRO' as const,
name: 'Pro',
nameRu: 'Профессиональный',
description: 'For power users and teams',
descriptionRu: 'Для профессионалов и команд',
price: 29.99,
priceRu: PRICE_RUB.PRO,
currency: 'USD',
features: [
'15 courses per month',
'Best AI model available',
'Priority support 24/7',
'Advanced editing tools',
'Custom AI model selection',
'Export to PDF (coming soon)',
],
featuresRu: [
'15 курсов в месяц',
'Лучшая доступная нейросеть',
'Приоритетная поддержка 24/7',
'Расширенные инструменты редактирования',
'Выбор модели нейросети',
'Экспорт в PDF (скоро)',
],
stripePriceId: process.env.STRIPE_PRICE_PRO || null,
},
] as const;
export type SubscriptionPlan = (typeof SUBSCRIPTION_PLANS)[number];
/** Format plan price for display. Uses RUB when language is Russian. */
export function formatPlanPrice(
plan: { price: number; priceRu: number; currency: string },
language: string
): { amount: number; currency: string; formatted: string } {
const isRu = language === 'ru' || language.startsWith('ru');
if (isRu) {
const amount = plan.priceRu;
return {
amount,
currency: 'RUB',
formatted: amount === 0 ? 'Бесплатно' : `${amount.toLocaleString('ru-RU')}`,
};
}
return {
amount: plan.price,
currency: plan.currency,
formatted: plan.price === 0 ? 'Free' : `$${plan.price}`,
};
}
// Generation progress steps
export const GENERATION_STEPS = {
PENDING: { progress: 0, label: 'Waiting to start', labelRu: 'Ожидание запуска' },
ANALYZING: { progress: 10, label: 'Analyzing your request', labelRu: 'Анализ запроса' },
ASKING_QUESTIONS: { progress: 15, label: 'Preparing questions', labelRu: 'Подготовка вопросов' },
WAITING_FOR_ANSWERS: { progress: 20, label: 'Waiting for your answers', labelRu: 'Ожидание ваших ответов' },
RESEARCHING: { progress: 30, label: 'Researching the topic', labelRu: 'Исследование темы' },
GENERATING_OUTLINE: { progress: 50, label: 'Creating course structure', labelRu: 'Создание структуры курса' },
GENERATING_CONTENT: { progress: 70, label: 'Writing course content', labelRu: 'Написание содержания' },
COMPLETED: { progress: 100, label: 'Course completed!', labelRu: 'Курс готов!' },
FAILED: { progress: 0, label: 'Generation failed', labelRu: 'Ошибка генерации' },
CANCELLED: { progress: 0, label: 'Generation cancelled', labelRu: 'Генерация отменена' },
} as const;
// API Routes
export const API_ROUTES = {
AUTH: {
ME: '/auth/me',
CALLBACK: '/auth/callback',
LOGOUT: '/auth/logout',
},
USERS: {
PROFILE: '/users/profile',
SETTINGS: '/users/settings',
},
COURSES: {
LIST: '/courses',
CREATE: '/courses',
GET: (id: string) => `/courses/${id}`,
UPDATE: (id: string) => `/courses/${id}`,
DELETE: (id: string) => `/courses/${id}`,
},
CHAPTERS: {
LIST: (courseId: string) => `/courses/${courseId}/chapters`,
CREATE: (courseId: string) => `/courses/${courseId}/chapters`,
UPDATE: (courseId: string, chapterId: string) => `/courses/${courseId}/chapters/${chapterId}`,
DELETE: (courseId: string, chapterId: string) => `/courses/${courseId}/chapters/${chapterId}`,
REORDER: (courseId: string) => `/courses/${courseId}/chapters/reorder`,
},
LESSONS: {
GET: (courseId: string, lessonId: string) => `/courses/${courseId}/lessons/${lessonId}`,
UPDATE: (courseId: string, lessonId: string) => `/courses/${courseId}/lessons/${lessonId}`,
},
GENERATION: {
START: '/generation/start',
STATUS: (id: string) => `/generation/${id}/status`,
ANSWER: (id: string) => `/generation/${id}/answer`,
CANCEL: (id: string) => `/generation/${id}/cancel`,
},
SUBSCRIPTIONS: {
PLANS: '/subscriptions/plans',
CURRENT: '/subscriptions/current',
CHECKOUT: '/subscriptions/checkout',
PORTAL: '/subscriptions/portal',
},
SEARCH: {
COURSES: '/search/courses',
},
} as const;
// Validation
export const VALIDATION = {
COURSE: {
TITLE_MIN: 3,
TITLE_MAX: 200,
DESCRIPTION_MAX: 5000,
},
CHAPTER: {
TITLE_MIN: 2,
TITLE_MAX: 200,
},
LESSON: {
TITLE_MIN: 2,
TITLE_MAX: 200,
},
PROMPT: {
MIN: 10,
MAX: 2000,
},
} as const;

View File

@ -0,0 +1,4 @@
// Export all shared types and utilities
export * from './types';
export * from './constants';
export * from './utils';

View File

@ -0,0 +1,197 @@
// API Response types
export interface ApiResponse<T = unknown> {
success: boolean;
data?: T;
error?: {
code: string;
message: string;
details?: unknown;
};
meta?: {
page?: number;
limit?: number;
total?: number;
totalPages?: number;
};
}
// User types
export interface UserProfile {
id: string;
email: string;
name: string | null;
avatarUrl: string | null;
subscriptionTier: SubscriptionTierType;
createdAt: string;
}
export type SubscriptionTierType = 'FREE' | 'PREMIUM' | 'PRO';
export interface UserSettings {
customAiModel: string | null;
emailNotifications: boolean;
marketingEmails: boolean;
theme: 'light' | 'dark' | 'system';
language: string;
}
// Course types
export interface CourseListItem {
id: string;
title: string;
description: string | null;
slug: string;
coverImage: string | null;
status: CourseStatusType;
chaptersCount: number;
lessonsCount: number;
createdAt: string;
updatedAt: string;
}
export type CourseStatusType = 'DRAFT' | 'GENERATING' | 'PUBLISHED' | 'ARCHIVED';
export interface CourseDetail extends CourseListItem {
chapters: ChapterWithLessons[];
author: {
id: string;
name: string | null;
avatarUrl: string | null;
};
}
export interface ChapterWithLessons {
id: string;
title: string;
description: string | null;
order: number;
lessons: LessonSummary[];
}
export interface LessonSummary {
id: string;
title: string;
order: number;
durationMinutes: number | null;
}
export interface LessonContent {
id: string;
title: string;
content: TipTapContent | null;
durationMinutes: number | null;
videoUrl: string | null;
}
// TipTap content type
export interface TipTapContent {
type: 'doc';
content: TipTapNode[];
}
export interface TipTapNode {
type: string;
attrs?: Record<string, unknown>;
content?: TipTapNode[];
marks?: TipTapMark[];
text?: string;
}
export interface TipTapMark {
type: string;
attrs?: Record<string, unknown>;
}
// Generation types
export type GenerationStatusType =
| 'PENDING'
| 'ANALYZING'
| 'ASKING_QUESTIONS'
| 'WAITING_FOR_ANSWERS'
| 'RESEARCHING'
| 'GENERATING_OUTLINE'
| 'GENERATING_CONTENT'
| 'COMPLETED'
| 'FAILED'
| 'CANCELLED';
export interface GenerationProgress {
id: string;
status: GenerationStatusType;
progress: number;
currentStep: string | null;
questions?: ClarifyingQuestion[];
generatedOutline?: CourseOutline;
errorMessage?: string;
}
export interface ClarifyingQuestion {
id: string;
question: string;
type: 'single_choice' | 'multiple_choice' | 'text';
options?: string[];
required: boolean;
}
export interface QuestionAnswer {
questionId: string;
answer: string | string[];
}
export interface CourseOutline {
title: string;
description: string;
chapters: {
title: string;
description: string;
lessons: {
title: string;
estimatedMinutes: number;
}[];
}[];
estimatedTotalHours: number;
difficulty: 'beginner' | 'intermediate' | 'advanced';
tags: string[];
}
// Subscription types
export interface SubscriptionPlan {
tier: SubscriptionTierType;
name: string;
description: string;
price: number;
currency: string;
features: string[];
limits: {
coursesPerMonth: number;
defaultAiModel: string;
};
stripePriceId: string | null;
}
export interface CurrentSubscription {
tier: SubscriptionTierType;
status: string;
currentPeriodEnd: string | null;
cancelAtPeriodEnd: boolean;
coursesCreatedThisMonth: number;
coursesLimit: number;
}
// Marketplace types (future)
export interface MarketplaceCourse extends CourseListItem {
price: number | null;
currency: string;
author: {
id: string;
name: string | null;
avatarUrl: string | null;
};
averageRating: number | null;
enrollmentCount: number;
category: {
id: string;
name: string;
slug: string;
} | null;
}

View File

@ -0,0 +1,128 @@
/**
* Generate a URL-friendly slug from a string
*/
export function generateSlug(text: string): string {
return text
.toLowerCase()
.trim()
.replace(/[^\w\s-]/g, '')
.replace(/[\s_-]+/g, '-')
.replace(/^-+|-+$/g, '');
}
/**
* Generate a unique slug by appending a random suffix
*/
export function generateUniqueSlug(text: string): string {
const baseSlug = generateSlug(text);
const suffix = Math.random().toString(36).substring(2, 8);
return `${baseSlug}-${suffix}`;
}
/**
* Format a price for display
*/
export function formatPrice(amount: number, currency: string = 'USD'): string {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency,
}).format(amount);
}
/**
* Format a date for display
*/
export function formatDate(date: string | Date, locale: string = 'ru-RU'): string {
const d = typeof date === 'string' ? new Date(date) : date;
return new Intl.DateTimeFormat(locale, {
year: 'numeric',
month: 'long',
day: 'numeric',
}).format(d);
}
/**
* Format a relative time (e.g., "2 days ago")
*/
export function formatRelativeTime(date: string | Date, locale: string = 'ru-RU'): string {
const d = typeof date === 'string' ? new Date(date) : date;
const now = new Date();
const diffInSeconds = Math.floor((now.getTime() - d.getTime()) / 1000);
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: 'auto' });
if (diffInSeconds < 60) {
return rtf.format(-diffInSeconds, 'second');
} else if (diffInSeconds < 3600) {
return rtf.format(-Math.floor(diffInSeconds / 60), 'minute');
} else if (diffInSeconds < 86400) {
return rtf.format(-Math.floor(diffInSeconds / 3600), 'hour');
} else if (diffInSeconds < 2592000) {
return rtf.format(-Math.floor(diffInSeconds / 86400), 'day');
} else if (diffInSeconds < 31536000) {
return rtf.format(-Math.floor(diffInSeconds / 2592000), 'month');
} else {
return rtf.format(-Math.floor(diffInSeconds / 31536000), 'year');
}
}
/**
* Truncate text to a maximum length
*/
export function truncateText(text: string, maxLength: number): string {
if (text.length <= maxLength) return text;
return text.slice(0, maxLength - 3) + '...';
}
/**
* Format duration in minutes to human readable
*/
export function formatDuration(minutes: number, locale: string = 'ru'): string {
if (minutes < 60) {
return locale === 'ru' ? `${minutes} мин` : `${minutes} min`;
}
const hours = Math.floor(minutes / 60);
const remainingMinutes = minutes % 60;
if (locale === 'ru') {
return remainingMinutes > 0
? `${hours} ч ${remainingMinutes} мин`
: `${hours} ч`;
}
return remainingMinutes > 0
? `${hours}h ${remainingMinutes}m`
: `${hours}h`;
}
/**
* Calculate estimated reading time from content
*/
export function calculateReadingTime(text: string, wordsPerMinute: number = 200): number {
const words = text.trim().split(/\s+/).length;
return Math.ceil(words / wordsPerMinute);
}
/**
* Validate email format
*/
export function isValidEmail(email: string): boolean {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
return emailRegex.test(email);
}
/**
* Sleep utility for async operations
*/
export function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Chunk an array into smaller arrays
*/
export function chunkArray<T>(array: T[], size: number): T[][] {
const chunks: T[][] = [];
for (let i = 0; i < array.length; i += size) {
chunks.push(array.slice(i, i + size));
}
return chunks;
}

View File

@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "./dist",
"rootDir": "./src"
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}