6989a98d75
- 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
350 lines
14 KiB
TypeScript
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;
|