/** * Database schema for the Global Content Network * Uses Drizzle ORM with SQLite */ import { sqliteTable, text, integer, real, index, uniqueIndex } from 'drizzle-orm/sqlite-core'; import { sql } from 'drizzle-orm'; // === CORE CONTENT TABLES === export const pages = sqliteTable('pages', { id: integer('id').primaryKey({ autoIncrement: true }), // Identification slug: text('slug').notNull(), // URL slug: "products/quiXzoom-pro" brand: text('brand', { enum: ['landvex', 'quixzoom'] }).notNull(), contentType: text('content_type', { enum: ['page', 'product', 'blog_post', 'faq', 'case_study', 'doc', 'whitepaper', 'glossary', 'landing'] }).notNull(), // Source of truth (Tier 1) sourceDomain: text('source_domain').notNull(), // e.g., "quixzoom.com" sourceLanguage: text('source_language').notNull().default('en-US'), // Versioning version: integer('version').notNull().default(1), majorVersion: integer('major_version').notNull().default(1), minorVersion: integer('minor_version').notNull().default(0), // Content (stored as JSON for flexibility) title: text('title').notNull(), metaDescription: text('meta_description'), metaKeywords: text('meta_keywords'), content: text('content').notNull(), // Markdown/HTML content structuredData: text('structured_data'), // JSON-LD / Schema.org // Media featuredImage: text('featured_image'), galleryImages: text('gallery_images'), // JSON array // Status status: text('status', { enum: ['draft', 'review', 'approved', 'published', 'archived'] }).notNull().default('draft'), // SEO canonicalUrl: text('canonical_url'), ogTitle: text('og_title'), ogDescription: text('og_description'), ogImage: text('og_image'), // Timestamps createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()), updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()), publishedAt: integer('published_at', { mode: 'timestamp' }), // Authorship createdBy: integer('created_by').references(() => users.id), updatedBy: integer('updated_by').references(() => users.id), // Workflow needsTranslationUpdate: integer('needs_translation_update', { mode: 'boolean' }).notNull().default(false), lastTranslationSync: integer('last_translation_sync', { mode: 'timestamp' }), }, (table) => ({ slugBrandIdx: uniqueIndex('slug_brand_idx').on(table.slug, table.brand), brandTypeIdx: index('brand_type_idx').on(table.brand, table.contentType), statusIdx: index('status_idx').on(table.status), needsTranslationIdx: index('needs_translation_idx').on(table.needsTranslationUpdate), })); export const translations = sqliteTable('translations', { id: integer('id').primaryKey({ autoIncrement: true }), // Link to source page pageId: integer('page_id').notNull().references(() => pages.id, { onDelete: 'cascade' }), // Target targetDomain: text('target_domain').notNull(), // e.g., "quixzoom.se" targetLanguage: text('target_language').notNull(), // e.g., "sv-SE" targetRegion: text('target_region'), // e.g., "EU", "APAC" // Content title: text('title').notNull(), metaDescription: text('meta_description'), metaKeywords: text('meta_keywords'), content: text('content').notNull(), structuredData: text('structured_data'), // Local adaptation localExamples: text('local_examples'), // JSON: localized examples localCases: text('local_cases'), // JSON: local case studies localRegulations: text('local_regulations'), // JSON: relevant local laws localContacts: text('local_contacts'), // JSON: localized contact info localCurrency: text('local_currency'), localPricing: text('local_pricing'), // JSON: localized pricing // Status workflow translationStatus: text('translation_status', { enum: ['pending', 'ai_translated', 'human_reviewed', 'locally_adapted', 'regional_approved', 'published', 'outdated'] }).notNull().default('pending'), // Review tracking aiTranslationDate: integer('ai_translation_date', { mode: 'timestamp' }), humanReviewDate: integer('human_review_date', { mode: 'timestamp' }), localAdaptationDate: integer('local_adaptation_date', { mode: 'timestamp' }), regionalApprovalDate: integer('regional_approval_date', { mode: 'timestamp' }), publishedDate: integer('published_date', { mode: 'timestamp' }), // Reviewers aiModel: text('ai_model'), // e.g., "gpt-4o-2024-08-06" reviewedBy: integer('reviewed_by').references(() => users.id), adaptedBy: integer('adapted_by').references(() => users.id), approvedBy: integer('approved_by').references(() => users.id), // Version tracking sourceVersion: integer('source_version').notNull(), translationVersion: integer('translation_version').notNull().default(1), // Quality metrics qualityScore: real('quality_score'), // 0-100 AI quality estimate seoScore: real('seo_score'), // 0-100 SEO optimization score // SEO canonicalUrl: text('canonical_url'), hreflang: text('hreflang').notNull(), // e.g., "sv-se" ogTitle: text('og_title'), ogDescription: text('og_description'), ogImage: text('og_image'), // Timestamps createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()), updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()), }, (table) => ({ pageLangIdx: uniqueIndex('page_lang_idx').on(table.pageId, table.targetLanguage), statusIdx: index('translation_status_idx').on(table.translationStatus), domainIdx: index('target_domain_idx').on(table.targetDomain), outdatedIdx: index('outdated_idx').on(table.translationStatus, table.updatedAt), })); // === WORKFLOW & QUEUE === export const translationJobs = sqliteTable('translation_jobs', { id: integer('id').primaryKey({ autoIncrement: true }), pageId: integer('page_id').notNull().references(() => pages.id), targetLanguage: text('target_language').notNull(), targetDomain: text('target_domain').notNull(), // Job status status: text('job_status', { enum: ['queued', 'processing', 'completed', 'failed', 'cancelled'] }).notNull().default('queued'), // Processing priority: integer('priority').notNull().default(5), // 1-10, lower = higher priority startedAt: integer('started_at', { mode: 'timestamp' }), completedAt: integer('completed_at', { mode: 'timestamp' }), errorMessage: text('error_message'), // AI details aiModel: text('ai_model'), tokensUsed: integer('tokens_used'), costEstimate: real('cost_estimate'), // USD // Queue integration bullJobId: text('bull_job_id'), createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()), }, (table) => ({ pageIdx: index('job_page_idx').on(table.pageId), statusIdx: index('job_status_idx').on(table.status), })); // === USERS & PERMISSIONS === export const users = sqliteTable('users', { id: integer('id').primaryKey({ autoIncrement: true }), email: text('email').notNull().unique(), name: text('name').notNull(), role: text('role', { enum: ['admin', 'editor', 'translator', 'reviewer', 'regional_manager', 'viewer'] }).notNull().default('viewer'), // Regional assignments assignedBrands: text('assigned_brands'), // JSON: ["landvex", "quixzoom"] assignedLanguages: text('assigned_languages'), // JSON: ["sv-SE", "de-DE"] assignedRegions: text('assigned_regions'), // JSON: ["EU", "APAC"] // Profile avatar: text('avatar'), timezone: text('timezone').default('UTC'), // Auth passwordHash: text('password_hash').notNull(), lastLogin: integer('last_login', { mode: 'timestamp' }), // Status isActive: integer('is_active', { mode: 'boolean' }).notNull().default(true), createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()), updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()), }); // === CHANGE LOG / AUDIT === export const changeLog = sqliteTable('change_log', { id: integer('id').primaryKey({ autoIncrement: true }), entityType: text('entity_type', { enum: ['page', 'translation', 'user', 'site_config'] }).notNull(), entityId: integer('entity_id').notNull(), action: text('action', { enum: ['created', 'updated', 'deleted', 'published', 'translated', 'reviewed', 'approved'] }).notNull(), // What changed fieldName: text('field_name'), oldValue: text('old_value'), newValue: text('new_value'), // Context performedBy: integer('performed_by').references(() => users.id), performedAt: integer('performed_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()), ipAddress: text('ip_address'), userAgent: text('user_agent'), // For translations: which language/region languageCode: text('language_code'), domain: text('domain'), }, (table) => ({ entityIdx: index('entity_idx').on(table.entityType, table.entityId), actionIdx: index('action_idx').on(table.action), timeIdx: index('time_idx').on(table.performedAt), })); // === SEO & ANALYTICS === export const seoMetrics = sqliteTable('seo_metrics', { id: integer('id').primaryKey({ autoIncrement: true }), pageId: integer('page_id').references(() => pages.id), translationId: integer('translation_id').references(() => translations.id), domain: text('domain').notNull(), language: text('language').notNull(), // Metrics url: text('url').notNull(), titleLength: integer('title_length'), metaDescriptionLength: integer('meta_description_length'), h1Count: integer('h1_count'), h2Count: integer('h2_count'), wordCount: integer('word_count'), internalLinks: integer('internal_links'), externalLinks: integer('external_links'), imageCount: integer('image_count'), imagesWithAlt: integer('images_with_alt'), hasCanonical: integer('has_canonical', { mode: 'boolean' }), hasHreflang: integer('has_hreflang', { mode: 'boolean' }), hasStructuredData: integer('has_structured_data', { mode: 'boolean' }), hasOgTags: integer('has_og_tags', { mode: 'boolean' }), // Scores seoScore: real('seo_score'), readabilityScore: real('readability_score'), keywordDensity: real('keyword_density'), // Audit date auditedAt: integer('audited_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()), }, (table) => ({ pageLangIdx: index('seo_page_lang_idx').on(table.pageId, table.language), domainIdx: index('seo_domain_idx').on(table.domain), })); // === GLOSSARY / TERMINOLOGY === export const glossary = sqliteTable('glossary', { id: integer('id').primaryKey({ autoIncrement: true }), term: text('term').notNull(), // Source term (English) brand: text('brand', { enum: ['landvex', 'quixzoom', 'both'] }).notNull().default('both'), context: text('context'), // Usage context partOfSpeech: text('part_of_speech'), // noun, verb, etc. // Domain-specific (for technical accuracy) category: text('category', { enum: ['product', 'technical', 'legal', 'marketing', 'support', 'general'] }).notNull().default('general'), // Approved translations per language translations: text('translations').notNull(), // JSON: {"sv-SE": "term", "de-DE": "Begriff"} // Status status: text('glossary_status', { enum: ['draft', 'approved', 'deprecated'] }).notNull().default('draft'), approvedBy: integer('approved_by').references(() => users.id), approvedAt: integer('approved_at', { mode: 'timestamp' }), createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()), updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()), }, (table) => ({ termIdx: uniqueIndex('term_idx').on(table.term, table.brand, table.category), brandIdx: index('glossary_brand_idx').on(table.brand), })); // === LOCALIZATION ASSETS === export const localizationAssets = sqliteTable('localization_assets', { id: integer('id').primaryKey({ autoIncrement: true }), name: text('name').notNull(), type: text('asset_type', { enum: ['image', 'document', 'video', 'audio', 'data'] }).notNull(), // Localization sourceAsset: text('source_asset').notNull(), // Path/URL to original localizedVersions: text('localized_versions').notNull(), // JSON: {"sv-SE": "path", "de-DE": "path"} // Context pageIds: text('page_ids'), // JSON: [1, 2, 3] brand: text('brand', { enum: ['landvex', 'quixzoom', 'both'] }).notNull().default('both'), // Metadata altText: text('alt_text'), // JSON per language captions: text('captions'), // JSON per language createdAt: integer('created_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()), updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull().$defaultFn(() => new Date()), }); // Type exports export type Page = typeof pages.$inferSelect; export type NewPage = typeof pages.$inferInsert; export type Translation = typeof translations.$inferSelect; export type NewTranslation = typeof translations.$inferInsert; export type TranslationJob = typeof translationJobs.$inferSelect; export type User = typeof users.$inferSelect; export type ChangeLog = typeof changeLog.$inferSelect; export type SeoMetric = typeof seoMetrics.$inferSelect; export type GlossaryEntry = typeof glossary.$inferSelect; export type LocalizationAsset = typeof localizationAssets.$inferSelect;