Files
boc/landvex-quixzoom-cms/src/db/schema.ts
T
Bernt 6989a98d75 feat: Passwordless cross-device authentication
- Arkitektur: docs/auth/passwordless-architecture.md
- Backend: iom/quixzoom-auth-service/ (FastAPI + Redis)
- Webb: quixzoom-market-pages/se/login/ (QR-kod + polling)
- App: iom/quixzoom-app/src/features/auth/ (push + deep links)

Flöde: QR-kod → app-godkännande → webb-inloggad
2026-07-07 07:11:50 +00:00

350 lines
14 KiB
TypeScript

/**
* 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;