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
This commit is contained in:
Bernt
2026-07-07 07:11:50 +00:00
parent 4aa984ad74
commit 6989a98d75
61843 changed files with 5491611 additions and 872231 deletions
+645
View File
@@ -0,0 +1,645 @@
/**
* Global site configuration for Landvex/QuiXzoom content network
* Defines all brands, tiers, languages, and regional settings
*/
export type SiteTier = 'global' | 'national' | 'regional';
export type ContentStatus = 'current' | 'outdated' | 'pending_review' | 'approved' | 'published';
export type ReviewStatus = 'ai_translated' | 'human_reviewed' | 'locally_adapted' | 'regional_approved';
export interface LanguageConfig {
code: string; // ISO 639-1 + region: en-US, sv-SE, de-DE
name: string; // Display name
nativeName: string; // Name in native language
direction: 'ltr' | 'rtl';
currency: string; // Default currency for this locale
dateFormat: string;
numberFormat: string;
seoLocale: string; // hreflang value
}
export interface SiteConfig {
domain: string;
brand: 'landvex' | 'quixzoom';
tier: SiteTier;
languages: LanguageConfig[];
defaultLanguage: string;
region?: string; // For regional hubs
countries?: string[]; // Countries covered
features: {
ecommerce: boolean;
blog: boolean;
faq: boolean;
cases: boolean;
docs: boolean;
contact: boolean;
};
contacts: {
supportEmail?: string;
salesEmail?: string;
phone?: string;
address?: string;
};
legal: {
companyName: string;
vatNumber?: string;
registrationNumber?: string;
jurisdiction: string;
};
}
// === GLOBAL SOURCE OF TRUTH (Tier 1) ===
export const GLOBAL_SITES: SiteConfig[] = [
{
domain: 'landvex.com',
brand: 'landvex',
tier: 'global',
languages: [{
code: 'en-US',
name: 'English (US)',
nativeName: 'English',
direction: 'ltr',
currency: 'USD',
dateFormat: 'MM/DD/YYYY',
numberFormat: 'en-US',
seoLocale: 'en-US'
}],
defaultLanguage: 'en-US',
features: {
ecommerce: true,
blog: true,
faq: true,
cases: true,
docs: true,
contact: true
},
contacts: {
supportEmail: 'support@landvex.com',
salesEmail: 'sales@landvex.com',
phone: '+1-800-LANDVEX'
},
legal: {
companyName: 'Landvex Inc.',
vatNumber: null as any,
registrationNumber: 'DE123456789',
jurisdiction: 'Delaware, USA'
}
},
{
domain: 'quixzoom.com',
brand: 'quixzoom',
tier: 'global',
languages: [{
code: 'en-US',
name: 'English (US)',
nativeName: 'English',
direction: 'ltr',
currency: 'USD',
dateFormat: 'MM/DD/YYYY',
numberFormat: 'en-US',
seoLocale: 'en-US'
}],
defaultLanguage: 'en-US',
features: {
ecommerce: true,
blog: true,
faq: true,
cases: true,
docs: true,
contact: true
},
contacts: {
supportEmail: 'support@quixzoom.com',
salesEmail: 'sales@quixzoom.com',
phone: '+1-800-QUIXZOOM'
},
legal: {
companyName: 'QuiXzoom Inc.',
vatNumber: null as any,
registrationNumber: 'DE987654321',
jurisdiction: 'Delaware, USA'
}
}
];
// === NATIONAL SITES (Tier 2) ===
export const NATIONAL_SITES: SiteConfig[] = [
// Sweden
{
domain: 'quixzoom.se',
brand: 'quixzoom',
tier: 'national',
languages: [{
code: 'sv-SE',
name: 'Swedish',
nativeName: 'Svenska',
direction: 'ltr',
currency: 'SEK',
dateFormat: 'YYYY-MM-DD',
numberFormat: 'sv-SE',
seoLocale: 'sv-SE'
}],
defaultLanguage: 'sv-SE',
features: {
ecommerce: true,
blog: true,
faq: true,
cases: true,
docs: true,
contact: true
},
contacts: {
supportEmail: 'support@quixzoom.se',
salesEmail: 'sales@quixzoom.se',
phone: '+46-8-123-456-78',
address: 'Stureplan 4C, 114 35 Stockholm'
},
legal: {
companyName: 'QuiXzoom Nordic AB',
vatNumber: 'SE556123456701',
registrationNumber: '556123-4567',
jurisdiction: 'Sweden'
}
},
{
domain: 'landvex.se',
brand: 'landvex',
tier: 'national',
languages: [{
code: 'sv-SE',
name: 'Swedish',
nativeName: 'Svenska',
direction: 'ltr',
currency: 'SEK',
dateFormat: 'YYYY-MM-DD',
numberFormat: 'sv-SE',
seoLocale: 'sv-SE'
}],
defaultLanguage: 'sv-SE',
features: {
ecommerce: true,
blog: true,
faq: true,
cases: true,
docs: true,
contact: true
},
contacts: {
supportEmail: 'support@landvex.se',
salesEmail: 'sales@landvex.se',
phone: '+46-8-987-654-32',
address: 'Kungsgatan 37, 111 56 Stockholm'
},
legal: {
companyName: 'Landvex Nordic AB',
vatNumber: 'SE556987654301',
registrationNumber: '556987-6543',
jurisdiction: 'Sweden'
}
},
// Germany
{
domain: 'quixzoom.de',
brand: 'quixzoom',
tier: 'national',
languages: [{
code: 'de-DE',
name: 'German',
nativeName: 'Deutsch',
direction: 'ltr',
currency: 'EUR',
dateFormat: 'DD.MM.YYYY',
numberFormat: 'de-DE',
seoLocale: 'de-DE'
}],
defaultLanguage: 'de-DE',
features: {
ecommerce: true,
blog: true,
faq: true,
cases: true,
docs: true,
contact: true
},
contacts: {
supportEmail: 'support@quixzoom.de',
salesEmail: 'vertrieb@quixzoom.de',
phone: '+49-30-12345678',
address: 'Friedrichstraße 171, 10117 Berlin'
},
legal: {
companyName: 'QuiXzoom GmbH',
vatNumber: 'DE123456789',
registrationNumber: 'HRB 123456 B',
jurisdiction: 'Germany'
}
},
// Netherlands
{
domain: 'quixzoom.nl',
brand: 'quixzoom',
tier: 'national',
languages: [{
code: 'nl-NL',
name: 'Dutch',
nativeName: 'Nederlands',
direction: 'ltr',
currency: 'EUR',
dateFormat: 'DD-MM-YYYY',
numberFormat: 'nl-NL',
seoLocale: 'nl-NL'
}],
defaultLanguage: 'nl-NL',
features: {
ecommerce: true,
blog: true,
faq: true,
cases: true,
docs: true,
contact: true
},
contacts: {
supportEmail: 'support@quixzoom.nl',
salesEmail: 'verkoop@quixzoom.nl',
phone: '+31-20-123-4567',
address: 'Herengracht 420, 1017 BZ Amsterdam'
},
legal: {
companyName: 'QuiXzoom B.V.',
vatNumber: 'NL123456789B01',
registrationNumber: '12345678',
jurisdiction: 'Netherlands'
}
},
// France
{
domain: 'quixzoom.fr',
brand: 'quixzoom',
tier: 'national',
languages: [{
code: 'fr-FR',
name: 'French',
nativeName: 'Français',
direction: 'ltr',
currency: 'EUR',
dateFormat: 'DD/MM/YYYY',
numberFormat: 'fr-FR',
seoLocale: 'fr-FR'
}],
defaultLanguage: 'fr-FR',
features: {
ecommerce: true,
blog: true,
faq: true,
cases: true,
docs: true,
contact: true
},
contacts: {
supportEmail: 'support@quixzoom.fr',
salesEmail: 'ventes@quixzoom.fr',
phone: '+33-1-23-45-67-89',
address: '25 Rue de la Paix, 75002 Paris'
},
legal: {
companyName: 'QuiXzoom SARL',
vatNumber: 'FR12345678901',
registrationNumber: '123 456 789 RCS Paris',
jurisdiction: 'France'
}
},
// Spain
{
domain: 'quixzoom.es',
brand: 'quixzoom',
tier: 'national',
languages: [{
code: 'es-ES',
name: 'Spanish',
nativeName: 'Español',
direction: 'ltr',
currency: 'EUR',
dateFormat: 'DD/MM/YYYY',
numberFormat: 'es-ES',
seoLocale: 'es-ES'
}],
defaultLanguage: 'es-ES',
features: {
ecommerce: true,
blog: true,
faq: true,
cases: true,
docs: true,
contact: true
},
contacts: {
supportEmail: 'soporte@quixzoom.es',
salesEmail: 'ventas@quixzoom.es',
phone: '+34-91-123-4567',
address: 'Calle de Alcalá 45, 28014 Madrid'
},
legal: {
companyName: 'QuiXzoom SL',
vatNumber: 'ESB12345678',
registrationNumber: 'B-12345678',
jurisdiction: 'Spain'
}
},
// Italy
{
domain: 'quixzoom.it',
brand: 'quixzoom',
tier: 'national',
languages: [{
code: 'it-IT',
name: 'Italian',
nativeName: 'Italiano',
direction: 'ltr',
currency: 'EUR',
dateFormat: 'DD/MM/YYYY',
numberFormat: 'it-IT',
seoLocale: 'it-IT'
}],
defaultLanguage: 'it-IT',
features: {
ecommerce: true,
blog: true,
faq: true,
cases: true,
docs: true,
contact: true
},
contacts: {
supportEmail: 'supporto@quixzoom.it',
salesEmail: 'vendite@quixzoom.it',
phone: '+39-02-1234-5678',
address: 'Via Monte Napoleone 8, 20121 Milano'
},
legal: {
companyName: 'QuiXzoom SRL',
vatNumber: 'IT12345678901',
registrationNumber: 'MI-1234567',
jurisdiction: 'Italy'
}
},
// Poland
{
domain: 'quixzoom.pl',
brand: 'quixzoom',
tier: 'national',
languages: [{
code: 'pl-PL',
name: 'Polish',
nativeName: 'Polski',
direction: 'ltr',
currency: 'PLN',
dateFormat: 'DD.MM.YYYY',
numberFormat: 'pl-PL',
seoLocale: 'pl-PL'
}],
defaultLanguage: 'pl-PL',
features: {
ecommerce: true,
blog: true,
faq: true,
cases: true,
docs: true,
contact: true
},
contacts: {
supportEmail: 'pomoc@quixzoom.pl',
salesEmail: 'sprzedaz@quixzoom.pl',
phone: '+48-22-123-4567',
address: 'ul. Marszałkowska 100, 00-017 Warszawa'
},
legal: {
companyName: 'QuiXzoom Sp. z o.o.',
vatNumber: 'PL1234567890',
registrationNumber: '0000123456',
jurisdiction: 'Poland'
}
}
];
// === REGIONAL HUBS (Tier 3) ===
export const REGIONAL_SITES: SiteConfig[] = [
{
domain: 'quixzoom.asia',
brand: 'quixzoom',
tier: 'regional',
region: 'asia-pacific',
countries: ['CN', 'JP', 'KR', 'TH', 'VN', 'ID', 'MY', 'IN', 'SG', 'TW', 'HK'],
languages: [
{ code: 'zh-CN', name: 'Chinese (Simplified)', nativeName: '简体中文', direction: 'ltr', currency: 'CNY', dateFormat: 'YYYY-MM-DD', numberFormat: 'zh-CN', seoLocale: 'zh-CN' },
{ code: 'zh-TW', name: 'Chinese (Traditional)', nativeName: '繁體中文', direction: 'ltr', currency: 'TWD', dateFormat: 'YYYY-MM-DD', numberFormat: 'zh-TW', seoLocale: 'zh-TW' },
{ code: 'ja-JP', name: 'Japanese', nativeName: '日本語', direction: 'ltr', currency: 'JPY', dateFormat: 'YYYY/MM/DD', numberFormat: 'ja-JP', seoLocale: 'ja-JP' },
{ code: 'ko-KR', name: 'Korean', nativeName: '한국어', direction: 'ltr', currency: 'KRW', dateFormat: 'YYYY.MM.DD', numberFormat: 'ko-KR', seoLocale: 'ko-KR' },
{ code: 'th-TH', name: 'Thai', nativeName: 'ไทย', direction: 'ltr', currency: 'THB', dateFormat: 'DD/MM/YYYY', numberFormat: 'th-TH', seoLocale: 'th-TH' },
{ code: 'vi-VN', name: 'Vietnamese', nativeName: 'Tiếng Việt', direction: 'ltr', currency: 'VND', dateFormat: 'DD/MM/YYYY', numberFormat: 'vi-VN', seoLocale: 'vi-VN' },
{ code: 'id-ID', name: 'Indonesian', nativeName: 'Bahasa Indonesia', direction: 'ltr', currency: 'IDR', dateFormat: 'DD/MM/YYYY', numberFormat: 'id-ID', seoLocale: 'id-ID' },
{ code: 'ms-MY', name: 'Malay', nativeName: 'Bahasa Melayu', direction: 'ltr', currency: 'MYR', dateFormat: 'DD/MM/YYYY', numberFormat: 'ms-MY', seoLocale: 'ms-MY' },
{ code: 'hi-IN', name: 'Hindi', nativeName: 'हिन्दी', direction: 'ltr', currency: 'INR', dateFormat: 'DD/MM/YYYY', numberFormat: 'hi-IN', seoLocale: 'hi-IN' }
],
defaultLanguage: 'en-US',
features: {
ecommerce: true,
blog: true,
faq: true,
cases: true,
docs: true,
contact: true
},
contacts: {
supportEmail: 'support@quixzoom.asia',
salesEmail: 'sales@quixzoom.asia',
phone: '+65-6123-4567',
address: '1 Raffles Place, #25-01, Singapore 048616'
},
legal: {
companyName: 'QuiXzoom Asia Pacific Pte. Ltd.',
vatNumber: null as any,
registrationNumber: '202312345K',
jurisdiction: 'Singapore'
}
},
{
domain: 'landvex.asia',
brand: 'landvex',
tier: 'regional',
region: 'asia-pacific',
countries: ['CN', 'JP', 'KR', 'TH', 'VN', 'ID', 'MY', 'IN', 'SG', 'TW', 'HK'],
languages: [
{ code: 'zh-CN', name: 'Chinese (Simplified)', nativeName: '简体中文', direction: 'ltr', currency: 'CNY', dateFormat: 'YYYY-MM-DD', numberFormat: 'zh-CN', seoLocale: 'zh-CN' },
{ code: 'zh-TW', name: 'Chinese (Traditional)', nativeName: '繁體中文', direction: 'ltr', currency: 'TWD', dateFormat: 'YYYY-MM-DD', numberFormat: 'zh-TW', seoLocale: 'zh-TW' },
{ code: 'ja-JP', name: 'Japanese', nativeName: '日本語', direction: 'ltr', currency: 'JPY', dateFormat: 'YYYY/MM/DD', numberFormat: 'ja-JP', seoLocale: 'ja-JP' },
{ code: 'ko-KR', name: 'Korean', nativeName: '한국어', direction: 'ltr', currency: 'KRW', dateFormat: 'YYYY.MM.DD', numberFormat: 'ko-KR', seoLocale: 'ko-KR' },
{ code: 'th-TH', name: 'Thai', nativeName: 'ไทย', direction: 'ltr', currency: 'THB', dateFormat: 'DD/MM/YYYY', numberFormat: 'th-TH', seoLocale: 'th-TH' },
{ code: 'vi-VN', name: 'Vietnamese', nativeName: 'Tiếng Việt', direction: 'ltr', currency: 'VND', dateFormat: 'DD/MM/YYYY', numberFormat: 'vi-VN', seoLocale: 'vi-VN' },
{ code: 'id-ID', name: 'Indonesian', nativeName: 'Bahasa Indonesia', direction: 'ltr', currency: 'IDR', dateFormat: 'DD/MM/YYYY', numberFormat: 'id-ID', seoLocale: 'id-ID' },
{ code: 'ms-MY', name: 'Malay', nativeName: 'Bahasa Melayu', direction: 'ltr', currency: 'MYR', dateFormat: 'DD/MM/YYYY', numberFormat: 'ms-MY', seoLocale: 'ms-MY' },
{ code: 'hi-IN', name: 'Hindi', nativeName: 'हिन्दी', direction: 'ltr', currency: 'INR', dateFormat: 'DD/MM/YYYY', numberFormat: 'hi-IN', seoLocale: 'hi-IN' }
],
defaultLanguage: 'en-US',
features: {
ecommerce: true,
blog: true,
faq: true,
cases: true,
docs: true,
contact: true
},
contacts: {
supportEmail: 'support@landvex.asia',
salesEmail: 'sales@landvex.asia',
phone: '+65-6987-6543',
address: 'Marina Bay Financial Centre, Tower 3, #15-01, Singapore 018982'
},
legal: {
companyName: 'Landvex Asia Pacific Pte. Ltd.',
vatNumber: null as any,
registrationNumber: '202398765K',
jurisdiction: 'Singapore'
}
}
];
// All sites combined
export const ALL_SITES: SiteConfig[] = [...GLOBAL_SITES, ...NATIONAL_SITES, ...REGIONAL_SITES];
// Helper functions
export function getSiteByDomain(domain: string): SiteConfig | undefined {
return ALL_SITES.find(s => s.domain === domain);
}
export function getSitesByBrand(brand: 'landvex' | 'quixzoom'): SiteConfig[] {
return ALL_SITES.filter(s => s.brand === brand);
}
export function getSitesByTier(tier: SiteTier): SiteConfig[] {
return ALL_SITES.filter(s => s.tier === tier);
}
export function getGlobalSource(brand: 'landvex' | 'quixzoom'): SiteConfig {
return ALL_SITES.find(s => s.tier === 'global' && s.brand === brand)!;
}
export function getLanguageConfig(site: SiteConfig, langCode: string): LanguageConfig | undefined {
return site.languages.find(l => l.code === langCode);
}
// Supported target languages for translation (all non-English)
export const TRANSLATION_TARGETS = [
'sv-SE', 'de-DE', 'nl-NL', 'fr-FR', 'es-ES', 'it-IT', 'pl-PL',
'zh-CN', 'zh-TW', 'ja-JP', 'ko-KR', 'th-TH', 'vi-VN', 'id-ID', 'ms-MY', 'hi-IN'
];
// Language-specific transcreation prompts
export const TRANSCREATION_PROMPTS: Record<string, string> = {
'sv-SE': `You are a Swedish transcreation expert. Adapt the content for Swedish audience:
- Use informal "du" (not formal "ni") for B2C, "ni" for formal B2B
- Reference Swedish regulations (GDPR, Swedish Consumer Agency)
- Use Swedish cultural references when appropriate
- Adapt examples to Swedish context (Stockholm, Gothenburg, Malmö)
- Currency: SEK, date format: YYYY-MM-DD`,
'de-DE': `You are a German transcreation expert. Adapt the content for German audience:
- Use formal "Sie" for business context
- Reference German regulations (BDSG, DSGVO)
- Be thorough and detailed — Germans expect completeness
- Adapt examples to German cities (Berlin, Munich, Hamburg)
- Currency: EUR, date format: DD.MM.YYYY`,
'nl-NL': `You are a Dutch transcreation expert. Adapt the content for Dutch audience:
- Use informal "je" for most contexts
- Reference Dutch regulations (AVG, ACM)
- Be direct and concise — Dutch appreciate straightforwardness
- Adapt examples to Dutch context (Amsterdam, Rotterdam, The Hague)
- Currency: EUR, date format: DD-MM-YYYY`,
'fr-FR': `You are a French transcreation expert. Adapt the content for French audience:
- Use formal "vous" for business
- Reference French regulations (RGPD, DGCCRF)
- Be elegant but clear — French appreciate quality expression
- Adapt examples to French context (Paris, Lyon, Marseille)
- Currency: EUR, date format: DD/MM/YYYY`,
'es-ES': `You are a Spanish transcreation expert. Adapt the content for Spanish audience:
- Use formal "usted" for business, "tú" for casual
- Reference Spanish regulations (LOPD, AEPD)
- Be warm and relationship-oriented
- Adapt examples to Spanish context (Madrid, Barcelona, Valencia)
- Currency: EUR, date format: DD/MM/YYYY`,
'it-IT': `You are an Italian transcreation expert. Adapt the content for Italian audience:
- Use formal "Lei" for business
- Reference Italian regulations (GDPR-IT, AGCM)
- Be expressive and passionate when appropriate
- Adapt examples to Italian context (Milan, Rome, Turin)
- Currency: EUR, date format: DD/MM/YYYY`,
'pl-PL': `You are a Polish transcreation expert. Adapt the content for Polish audience:
- Use formal "Pan/Pani" for business
- Reference Polish regulations (RODO, UOKiK)
- Be detailed and factual
- Adapt examples to Polish context (Warsaw, Kraków, Wrocław)
- Currency: PLN, date format: DD.MM.YYYY`,
'zh-CN': `You are a Chinese (Simplified) transcreation expert. Adapt for mainland China:
- Reference Chinese regulations (PRC Cybersecurity Law, PIPL)
- Use Simplified Chinese characters (简体中文)
- Be respectful and formal in business context
- Adapt examples to Chinese cities (Beijing, Shanghai, Shenzhen)
- Currency: CNY (RMB), date format: YYYY-MM-DD`,
'zh-TW': `You are a Chinese (Traditional) transcreation expert. Adapt for Taiwan:
- Reference Taiwanese regulations (PDPA Taiwan)
- Use Traditional Chinese characters (繁體中文)
- Be polite and formal in business
- Adapt examples to Taiwanese context (Taipei, Kaohsiung, Taichung)
- Currency: TWD, date format: YYYY-MM-DD`,
'ja-JP': `You are a Japanese transcreation expert. Adapt for Japanese audience:
- Use keigo (polite language) for business
- Reference Japanese regulations (APPI, Consumer Contract Act)
- Be extremely polite and indirect
- Adapt examples to Japanese context (Tokyo, Osaka, Kyoto)
- Currency: JPY, date format: YYYY/MM/DD`,
'ko-KR': `You are a Korean transcreation expert. Adapt for Korean audience:
- Use formal speech (존칭) for business
- Reference Korean regulations (PIPA, KCC)
- Be respectful and hierarchical
- Adapt examples to Korean context (Seoul, Busan, Incheon)
- Currency: KRW, date format: YYYY.MM.DD`,
'th-TH': `You are a Thai transcreation expert. Adapt for Thai audience:
- Be respectful and use polite particles
- Reference Thai regulations (PDPA Thailand)
- Build relationship first, business second
- Adapt examples to Thai context (Bangkok, Chiang Mai, Phuket)
- Currency: THB, date format: DD/MM/YYYY`,
'vi-VN': `You are a Vietnamese transcreation expert. Adapt for Vietnamese audience:
- Be respectful but warm
- Reference Vietnamese regulations (Cybersecurity Law, Decree 13)
- Adapt examples to Vietnamese context (Ho Chi Minh City, Hanoi, Da Nang)
- Currency: VND, date format: DD/MM/YYYY`,
'id-ID': `You are an Indonesian transcreation expert. Adapt for Indonesian audience:
- Be friendly and approachable
- Reference Indonesian regulations (PDP Law, ITE Law)
- Adapt examples to Indonesian context (Jakarta, Surabaya, Bandung)
- Currency: IDR, date format: DD/MM/YYYY`,
'ms-MY': `You are a Malay transcreation expert. Adapt for Malaysian audience:
- Be respectful and inclusive (multicultural context)
- Reference Malaysian regulations (PDPA Malaysia)
- Adapt examples to Malaysian context (Kuala Lumpur, Penang, Johor)
- Currency: MYR, date format: DD/MM/YYYY`,
'hi-IN': `You are a Hindi transcreation expert. Adapt for Indian audience:
- Mix Hindi and English (Hinglish) is common for tech
- Reference Indian regulations (DPDP Act 2023, IT Act)
- Adapt examples to Indian context (Mumbai, Delhi, Bangalore)
- Currency: INR, date format: DD/MM/YYYY`
};
+21
View File
@@ -0,0 +1,21 @@
/**
* Database connection and initialization
*/
import Database from 'better-sqlite3';
import { drizzle } from 'drizzle-orm/better-sqlite3';
import * as schema from './schema.js';
import { mkdirSync } from 'fs';
import { dirname } from 'path';
const dbPath = process.env.DATABASE_URL || './data/content.db';
// Ensure data directory exists
mkdirSync(dirname(dbPath), { recursive: true });
const sqlite = new Database(dbPath);
sqlite.pragma('journal_mode = WAL');
sqlite.pragma('foreign_keys = ON');
export const db = drizzle(sqlite, { schema });
export { schema };
+221
View File
@@ -0,0 +1,221 @@
/**
* Database migration script
* Creates all tables for the Global Content Network
*/
import { db } from './index.js';
import { sql } from 'drizzle-orm';
console.log('Running migrations...');
db.run(sql`
-- Pages table (Source of Truth)
CREATE TABLE IF NOT EXISTS pages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
slug TEXT NOT NULL,
brand TEXT NOT NULL CHECK(brand IN ('landvex', 'quixzoom')),
content_type TEXT NOT NULL CHECK(content_type IN ('page', 'product', 'blog_post', 'faq', 'case_study', 'doc', 'whitepaper', 'glossary', 'landing')),
source_domain TEXT NOT NULL,
source_language TEXT NOT NULL DEFAULT 'en-US',
version INTEGER NOT NULL DEFAULT 1,
major_version INTEGER NOT NULL DEFAULT 1,
minor_version INTEGER NOT NULL DEFAULT 0,
title TEXT NOT NULL,
meta_description TEXT,
meta_keywords TEXT,
content TEXT NOT NULL,
structured_data TEXT,
featured_image TEXT,
gallery_images TEXT,
status TEXT NOT NULL DEFAULT 'draft' CHECK(status IN ('draft', 'review', 'approved', 'published', 'archived')),
canonical_url TEXT,
og_title TEXT,
og_description TEXT,
og_image TEXT,
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
updated_at INTEGER NOT NULL DEFAULT (unixepoch()),
published_at INTEGER,
created_by INTEGER,
updated_by INTEGER,
needs_translation_update INTEGER NOT NULL DEFAULT 0,
last_translation_sync INTEGER,
UNIQUE(slug, brand)
);
CREATE INDEX IF NOT EXISTS brand_type_idx ON pages(brand, content_type);
CREATE INDEX IF NOT EXISTS status_idx ON pages(status);
CREATE INDEX IF NOT EXISTS needs_translation_idx ON pages(needs_translation_update);
-- Translations table
CREATE TABLE IF NOT EXISTS translations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
page_id INTEGER NOT NULL,
target_domain TEXT NOT NULL,
target_language TEXT NOT NULL,
target_region TEXT,
title TEXT NOT NULL,
meta_description TEXT,
meta_keywords TEXT,
content TEXT NOT NULL,
structured_data TEXT,
local_examples TEXT,
local_cases TEXT,
local_regulations TEXT,
local_contacts TEXT,
local_currency TEXT,
local_pricing TEXT,
translation_status TEXT NOT NULL DEFAULT 'pending' CHECK(translation_status IN ('pending', 'ai_translated', 'human_reviewed', 'locally_adapted', 'regional_approved', 'published', 'outdated')),
ai_translation_date INTEGER,
human_review_date INTEGER,
local_adaptation_date INTEGER,
regional_approval_date INTEGER,
published_date INTEGER,
ai_model TEXT,
reviewed_by INTEGER,
adapted_by INTEGER,
approved_by INTEGER,
source_version INTEGER NOT NULL,
translation_version INTEGER NOT NULL DEFAULT 1,
quality_score REAL,
seo_score REAL,
canonical_url TEXT,
hreflang TEXT NOT NULL,
og_title TEXT,
og_description TEXT,
og_image TEXT,
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
updated_at INTEGER NOT NULL DEFAULT (unixepoch()),
UNIQUE(page_id, target_language),
FOREIGN KEY (page_id) REFERENCES pages(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS translation_status_idx ON translations(translation_status);
CREATE INDEX IF NOT EXISTS target_domain_idx ON translations(target_domain);
CREATE INDEX IF NOT EXISTS outdated_idx ON translations(translation_status, updated_at);
-- Translation jobs
CREATE TABLE IF NOT EXISTS translation_jobs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
page_id INTEGER NOT NULL,
target_language TEXT NOT NULL,
target_domain TEXT NOT NULL,
job_status TEXT NOT NULL DEFAULT 'queued' CHECK(job_status IN ('queued', 'processing', 'completed', 'failed', 'cancelled')),
priority INTEGER NOT NULL DEFAULT 5,
started_at INTEGER,
completed_at INTEGER,
error_message TEXT,
ai_model TEXT,
tokens_used INTEGER,
cost_estimate REAL,
bull_job_id TEXT,
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
FOREIGN KEY (page_id) REFERENCES pages(id)
);
CREATE INDEX IF NOT EXISTS job_page_idx ON translation_jobs(page_id);
CREATE INDEX IF NOT EXISTS job_status_idx ON translation_jobs(job_status);
-- Users
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
email TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'viewer' CHECK(role IN ('admin', 'editor', 'translator', 'reviewer', 'regional_manager', 'viewer')),
assigned_brands TEXT,
assigned_languages TEXT,
assigned_regions TEXT,
avatar TEXT,
timezone TEXT DEFAULT 'UTC',
password_hash TEXT NOT NULL,
last_login INTEGER,
is_active INTEGER NOT NULL DEFAULT 1,
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
);
-- Change log
CREATE TABLE IF NOT EXISTS change_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
entity_type TEXT NOT NULL CHECK(entity_type IN ('page', 'translation', 'user', 'site_config')),
entity_id INTEGER NOT NULL,
action TEXT NOT NULL CHECK(action IN ('created', 'updated', 'deleted', 'published', 'translated', 'reviewed', 'approved')),
field_name TEXT,
old_value TEXT,
new_value TEXT,
performed_by INTEGER,
performed_at INTEGER NOT NULL DEFAULT (unixepoch()),
ip_address TEXT,
user_agent TEXT,
language_code TEXT,
domain TEXT
);
CREATE INDEX IF NOT EXISTS entity_idx ON change_log(entity_type, entity_id);
CREATE INDEX IF NOT EXISTS action_idx ON change_log(action);
CREATE INDEX IF NOT EXISTS time_idx ON change_log(performed_at);
-- SEO metrics
CREATE TABLE IF NOT EXISTS seo_metrics (
id INTEGER PRIMARY KEY AUTOINCREMENT,
page_id INTEGER,
translation_id INTEGER,
domain TEXT NOT NULL,
language TEXT NOT NULL,
url TEXT NOT NULL,
title_length INTEGER,
meta_description_length INTEGER,
h1_count INTEGER,
h2_count INTEGER,
word_count INTEGER,
internal_links INTEGER,
external_links INTEGER,
image_count INTEGER,
images_with_alt INTEGER,
has_canonical INTEGER,
has_hreflang INTEGER,
has_structured_data INTEGER,
has_og_tags INTEGER,
seo_score REAL,
readability_score REAL,
keyword_density REAL,
audited_at INTEGER NOT NULL DEFAULT (unixepoch())
);
CREATE INDEX IF NOT EXISTS seo_page_lang_idx ON seo_metrics(page_id, language);
CREATE INDEX IF NOT EXISTS seo_domain_idx ON seo_metrics(domain);
-- Glossary
CREATE TABLE IF NOT EXISTS glossary (
id INTEGER PRIMARY KEY AUTOINCREMENT,
term TEXT NOT NULL,
brand TEXT NOT NULL DEFAULT 'both' CHECK(brand IN ('landvex', 'quixzoom', 'both')),
context TEXT,
part_of_speech TEXT,
category TEXT NOT NULL DEFAULT 'general' CHECK(category IN ('product', 'technical', 'legal', 'marketing', 'support', 'general')),
translations TEXT NOT NULL,
glossary_status TEXT NOT NULL DEFAULT 'draft' CHECK(glossary_status IN ('draft', 'approved', 'deprecated')),
approved_by INTEGER,
approved_at INTEGER,
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
updated_at INTEGER NOT NULL DEFAULT (unixepoch()),
UNIQUE(term, brand, category)
);
CREATE INDEX IF NOT EXISTS glossary_brand_idx ON glossary(brand);
-- Localization assets
CREATE TABLE IF NOT EXISTS localization_assets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
asset_type TEXT NOT NULL CHECK(asset_type IN ('image', 'document', 'video', 'audio', 'data')),
source_asset TEXT NOT NULL,
localized_versions TEXT NOT NULL,
page_ids TEXT,
brand TEXT NOT NULL DEFAULT 'both' CHECK(brand IN ('landvex', 'quixzoom', 'both')),
alt_text TEXT,
captions TEXT,
created_at INTEGER NOT NULL DEFAULT (unixepoch()),
updated_at INTEGER NOT NULL DEFAULT (unixepoch())
);
`);
console.log('Migrations completed successfully!');
+349
View File
@@ -0,0 +1,349 @@
/**
* 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;
+490
View File
@@ -0,0 +1,490 @@
/**
* Seed data for the Global Content Network
* Creates sample content demonstrating the 3-tier architecture
*/
import { db } from './index.js';
import { pages, translations, users, glossary } from './schema.js';
import { eq } from 'drizzle-orm';
import { hash } from '../lib/auth.js';
console.log('Seeding database...');
// Create admin user
const adminPassword = await hash(process.env.ADMIN_PASSWORD || 'changeme');
const admin = db.insert(users).values({
email: process.env.ADMIN_EMAIL || 'admin@landvex.com',
name: 'System Administrator',
role: 'admin',
passwordHash: adminPassword,
assignedBrands: JSON.stringify(['landvex', 'quixzoom']),
assignedLanguages: JSON.stringify(['en-US', 'sv-SE', 'de-DE']),
assignedRegions: JSON.stringify(['EU', 'APAC', 'NA']),
}).returning().get();
console.log('Created admin user:', admin.email);
// Create sample glossary entries
const glossaryEntries = [
{
term: 'QuiXzoom Pro',
brand: 'quixzoom' as const,
category: 'product' as const,
context: 'Flagship product name',
translations: JSON.stringify({
'sv-SE': 'QuiXzoom Pro',
'de-DE': 'QuiXzoom Pro',
'nl-NL': 'QuiXzoom Pro',
'fr-FR': 'QuiXzoom Pro',
'es-ES': 'QuiXzoom Pro',
'it-IT': 'QuiXzoom Pro',
'pl-PL': 'QuiXzoom Pro',
'zh-CN': 'QuiXzoom 专业版',
'zh-TW': 'QuiXzoom 專業版',
'ja-JP': 'QuiXzoom プロ',
'ko-KR': 'QuiXzoom 프로',
'th-TH': 'QuiXzoom โปร',
'vi-VN': 'QuiXzoom Pro',
'id-ID': 'QuiXzoom Pro',
'ms-MY': 'QuiXzoom Pro',
'hi-IN': 'QuiXzoom प्रो'
}),
status: 'approved' as const,
approvedBy: admin.id,
approvedAt: new Date()
},
{
term: 'Landvex Enterprise',
brand: 'landvex' as const,
category: 'product' as const,
context: 'Enterprise solution name',
translations: JSON.stringify({
'sv-SE': 'Landvex Enterprise',
'de-DE': 'Landvex Enterprise',
'nl-NL': 'Landvex Enterprise',
'fr-FR': 'Landvex Enterprise',
'es-ES': 'Landvex Enterprise',
'it-IT': 'Landvex Enterprise',
'pl-PL': 'Landvex Enterprise',
'zh-CN': 'Landvex 企业版',
'zh-TW': 'Landvex 企業版',
'ja-JP': 'Landvex エンタープライズ',
'ko-KR': 'Landvex 엔터프라이즈',
'th-TH': 'Landvex เอ็นเตอร์ไพรส์',
'vi-VN': 'Landvex Enterprise',
'id-ID': 'Landvex Enterprise',
'ms-MY': 'Landvex Enterprise',
'hi-IN': 'Landvex एंटरप्राइज'
}),
status: 'approved' as const,
approvedBy: admin.id,
approvedAt: new Date()
},
{
term: 'AI-powered optimization',
brand: 'both' as const,
category: 'technical' as const,
context: 'Feature description',
translations: JSON.stringify({
'sv-SE': 'AI-driven optimering',
'de-DE': 'KI-gestützte Optimierung',
'nl-NL': 'AI-gestuurde optimalisatie',
'fr-FR': 'Optimisation pilotée par IA',
'es-ES': 'Optimización impulsada por IA',
'it-IT': 'Ottimizzazione basata su IA',
'pl-PL': 'Optymalizacja wspierana przez AI',
'zh-CN': 'AI驱动的优化',
'zh-TW': 'AI驅動的優化',
'ja-JP': 'AI駆動型最適化',
'ko-KR': 'AI 기반 최적화',
'th-TH': 'การปรับให้เหมาะสมด้วย AI',
'vi-VN': 'Tối ưu hóa dựa trên AI',
'id-ID': 'Optimasi berbasis AI',
'ms-MY': 'Pengoptimuman AI',
'hi-IN': 'AI-संचालित अनुकूलन'
}),
status: 'approved' as const,
approvedBy: admin.id,
approvedAt: new Date()
}
];
for (const entry of glossaryEntries) {
db.insert(glossary).values(entry).run();
}
console.log(`Created ${glossaryEntries.length} glossary entries`);
// Create sample pages (Source of Truth - Tier 1)
const samplePages = [
{
slug: 'products/quixzoom-pro',
brand: 'quixzoom' as const,
contentType: 'product' as const,
sourceDomain: 'quixzoom.com',
sourceLanguage: 'en-US',
version: 1,
majorVersion: 1,
minorVersion: 0,
title: 'QuiXzoom Pro — AI-Powered Workflow Optimization',
metaDescription: 'Transform your business workflows with QuiXzoom Pro. AI-powered optimization, real-time analytics, and seamless integrations.',
metaKeywords: 'workflow optimization, AI, business automation, productivity',
content: `# QuiXzoom Pro
## The Future of Workflow Optimization
QuiXzoom Pro leverages cutting-edge artificial intelligence to streamline your business processes, reduce operational costs, and boost team productivity by up to 300%.
### Key Features
- **AI-Powered Analysis**: Automatically identifies bottlenecks and inefficiencies
- **Real-time Dashboards**: Monitor performance metrics in real-time
- **Seamless Integrations**: Connect with 200+ tools including Slack, Teams, and Jira
- **Predictive Analytics**: Forecast workload and resource needs
- **Automated Reporting**: Generate comprehensive reports with one click
### Pricing
- Starter: $49/month per user
- Professional: $99/month per user
- Enterprise: Custom pricing
### Technical Specifications
- Cloud-native SaaS architecture
- SOC 2 Type II certified
- GDPR compliant
- 99.99% uptime SLA
- API-first design with REST and GraphQL endpoints
## Customer Success
> "QuiXzoom Pro reduced our project delivery time by 40% within the first quarter."
> — Sarah Chen, CTO at TechFlow Inc.
## Get Started
Start your free 14-day trial today. No credit card required.`,
structuredData: JSON.stringify({
"@context": "https://schema.org",
"@type": "SoftwareApplication",
"name": "QuiXzoom Pro",
"applicationCategory": "BusinessApplication",
"offers": {
"@type": "Offer",
"price": "49.00",
"priceCurrency": "USD"
},
"aggregateRating": {
"@type": "AggregateRating",
"ratingValue": "4.8",
"ratingCount": "1247"
}
}),
status: 'published' as const,
canonicalUrl: 'https://quixzoom.com/products/quixzoom-pro',
ogTitle: 'QuiXzoom Pro — AI-Powered Workflow Optimization',
ogDescription: 'Transform your business workflows with AI. Start your free trial.',
createdBy: admin.id,
updatedBy: admin.id,
publishedAt: new Date(),
needsTranslationUpdate: true
},
{
slug: 'solutions/enterprise',
brand: 'landvex' as const,
contentType: 'page' as const,
sourceDomain: 'landvex.com',
sourceLanguage: 'en-US',
version: 1,
majorVersion: 1,
minorVersion: 0,
title: 'Enterprise Solutions — Landvex',
metaDescription: 'Enterprise-grade solutions for large organizations. Scalable, secure, and compliant with global regulations.',
metaKeywords: 'enterprise software, scalable solutions, compliance, security',
content: `# Enterprise Solutions
## Built for Global Enterprises
Landvex Enterprise delivers the scalability, security, and compliance that global organizations demand.
### Enterprise Features
- **Multi-tenant Architecture**: Isolate data across business units
- **Advanced Security**: SSO, MFA, and role-based access control
- **Global Compliance**: GDPR, CCPA, HIPAA, and SOC 2 compliant
- **Custom Integrations**: Dedicated integration team
- **24/7 Premium Support**: Dedicated account manager
### Industries We Serve
- Financial Services
- Healthcare
- Manufacturing
- Retail
- Government
### Deployment Options
- Cloud (AWS, Azure, GCP)
- On-premises
- Hybrid
Contact our enterprise sales team for a customized demo.`,
structuredData: JSON.stringify({
"@context": "https://schema.org",
"@type": "Organization",
"name": "Landvex Enterprise",
"description": "Enterprise solutions for global organizations"
}),
status: 'published' as const,
canonicalUrl: 'https://landvex.com/solutions/enterprise',
createdBy: admin.id,
updatedBy: admin.id,
publishedAt: new Date(),
needsTranslationUpdate: true
},
{
slug: 'blog/ai-transcreation-guide',
brand: 'quixzoom' as const,
contentType: 'blog_post' as const,
sourceDomain: 'quixzoom.com',
sourceLanguage: 'en-US',
version: 1,
majorVersion: 1,
minorVersion: 0,
title: 'The Complete Guide to AI-Powered Transcreation',
metaDescription: 'Learn how AI transcreation goes beyond translation to create culturally relevant content for global markets.',
metaKeywords: 'transcreation, AI translation, localization, global marketing',
content: `# The Complete Guide to AI-Powered Transcreation
## What is Transcreation?
Transcreation goes far beyond translation. It's the art of adapting content from one language to another while maintaining its intent, style, tone, and context.
## Why AI Transcreation Matters
Traditional translation often misses:
- Cultural nuances
- Local humor and idioms
- Regional regulations
- Market-specific examples
### The Transcreation Process
1. **Source Analysis**: Understand intent and context
2. **Cultural Adaptation**: Adjust for local culture
3. **Creative Translation**: Rewrite with local flavor
4. **Local Review**: Native speaker validation
5. **SEO Optimization**: Local keyword research
## Best Practices
- Always work with native speakers
- Research local competitors
- Understand local regulations
- Test with local audiences
- Monitor performance metrics
## The Future
AI is making transcreation faster and more accessible, but human oversight remains essential for quality and cultural accuracy.`,
status: 'published' as const,
canonicalUrl: 'https://quixzoom.com/blog/ai-transcreation-guide',
createdBy: admin.id,
updatedBy: admin.id,
publishedAt: new Date(),
needsTranslationUpdate: true
}
];
for (const page of samplePages) {
db.insert(pages).values(page).run();
}
console.log(`Created ${samplePages.length} source pages`);
// Create sample translations for Swedish (Tier 2)
const quixzoomProPage = db.select().from(pages).where(eq(pages.slug, 'products/quixzoom-pro')).get();
if (quixzoomProPage) {
const svTranslation = {
pageId: quixzoomProPage.id,
targetDomain: 'quixzoom.se',
targetLanguage: 'sv-SE',
targetRegion: 'EU',
title: 'QuiXzoom Pro — AI-driven arbetsflödesoptimering',
metaDescription: 'Transformera dina affärsprocesser med QuiXzoom Pro. AI-driven optimering, realtidsanalys och sömlösa integrationer.',
metaKeywords: 'arbetsflödesoptimering, AI, affärsautomation, produktivitet',
content: `# QuiXzoom Pro
## Framtiden för arbetsflödesoptimering
QuiXzoom Pro använder banbrytande artificiell intelligens för att effektivisera dina affärsprocesser, minska operativa kostnader och öka teamets produktivitet med upp till 300%.
### Nyckelfunktioner
- **AI-driven analys**: Identifierar automatiskt flaskhalsar och ineffektivitet
- **Realtidsdashboardar**: Övervaka prestandamått i realtid
- **Sömlösa integrationer**: Koppla ihop med 200+ verktyg inklusive Slack, Teams och Jira
- **Prediktiv analys**: Förutse arbetsbelastning och resursbehov
- **Automatisk rapportering**: Generera omfattande rapporter med ett klick
### Priser
- Starter: 499 kr/månad per användare
- Professional: 999 kr/månad per användare
- Enterprise: Skräddarsytt pris
### Tekniska specifikationer
- Cloud-native SaaS-arkitektur
- SOC 2 Type II-certifierad
- GDPR-kompatibel
- 99,99% drifttid enligt SLA
- API-first design med REST och GraphQL-endpoints
## Kundcase
> "QuiXzoom Pro minskade vår projektleveranstid med 40% under det första kvartalet."
> — Anna Lindqvist, CTO på TechFlow AB
## Kom igång
Starta din kostnadsfria 14-dagars provperiod idag. Inget kreditkort krävs.`,
localExamples: JSON.stringify([
'Stockholmsbaserade TechFlow AB',
'Göteborgska tillverkningsföretag',
'Malmö startup-scen'
]),
localCases: JSON.stringify([
{
company: 'TechFlow AB',
location: 'Stockholm',
result: '40% snabbare projektleverans',
quote: 'QuiXzoom Pro förändrade hur vi arbetar'
}
]),
localRegulations: JSON.stringify([
'GDPR (Dataskyddsförordningen)',
'Swedish Consumer Agency (Konsumentverket)',
'Swedish Data Protection Authority (IMY)'
]),
localContacts: JSON.stringify({
supportEmail: 'support@quixzoom.se',
salesEmail: 'sales@quixzoom.se',
phone: '+46-8-123-456-78',
address: 'Stureplan 4C, 114 35 Stockholm'
}),
localCurrency: 'SEK',
localPricing: JSON.stringify({
starter: '499 kr/månad',
professional: '999 kr/månad',
enterprise: 'Kontakta oss för offert'
}),
translationStatus: 'published' as const,
aiModel: 'gpt-4o-2024-08-06',
sourceVersion: 1,
qualityScore: 92,
seoScore: 88,
canonicalUrl: 'https://quixzoom.se/produkter/quixzoom-pro',
hreflang: 'sv-se',
ogTitle: 'QuiXzoom Pro — AI-driven arbetsflödesoptimering',
ogDescription: 'Transformera dina affärsprocesser med AI. Starta din kostnadsfria provperiod.',
publishedDate: new Date()
};
db.insert(translations).values(svTranslation).run();
console.log('Created Swedish translation for QuiXzoom Pro');
}
// Create sample translation for German
if (quixzoomProPage) {
const deTranslation = {
pageId: quixzoomProPage.id,
targetDomain: 'quixzoom.de',
targetLanguage: 'de-DE',
targetRegion: 'EU',
title: 'QuiXzoom Pro — KI-gestützte Workflow-Optimierung',
metaDescription: 'Transformieren Sie Ihre Geschäftsprozesse mit QuiXzoom Pro. KI-gestützte Optimierung, Echtzeitanalysen und nahtlose Integrationen.',
metaKeywords: 'Workflow-Optimierung, KI, Geschäftsautomatisierung, Produktivität',
content: `# QuiXzoom Pro
## Die Zukunft der Workflow-Optimierung
QuiXzoom Pro nutzt modernste künstliche Intelligenz, um Ihre Geschäftsprozesse zu optimieren, operative Kosten zu senken und die Teamproduktivität um bis zu 300% zu steigern.
### Hauptfunktionen
- **KI-gestützte Analyse**: Erkennt automatisch Engpässe und Ineffizienzen
- **Echtzeit-Dashboards**: Überwachen Sie Leistungskennzahlen in Echtzeit
- **Nahtlose Integrationen**: Verbindung mit 200+ Tools inklusive Slack, Teams und Jira
- **Prädiktive Analytik**: Vorhersage von Arbeitsaufwand und Ressourcenbedarf
- **Automatisierte Berichterstattung**: Umfassende Berichte mit einem Klick
### Preise
- Starter: 49 €/Monat pro Nutzer
- Professional: 99 €/Monat pro Nutzer
- Enterprise: Individuelle Preisgestaltung
### Technische Spezifikationen
- Cloud-native SaaS-Architektur
- SOC 2 Type II zertifiziert
- DSGVO-konform
- 99,99% Verfügbarkeit laut SLA
- API-first Design mit REST und GraphQL Endpoints
## Kundenerfolg
> "QuiXzoom Pro reduzierte unsere Projektlieferzeit um 40% im ersten Quartal."
> — Sarah Chen, CTO bei TechFlow GmbH
## Jetzt starten
Starten Sie Ihre kostenlose 14-tägige Testphase. Keine Kreditkarte erforderlich.`,
localExamples: JSON.stringify([
'Berliner TechFlow GmbH',
'Münchner Fertigungsunternehmen',
'Hamburger Startup-Szene'
]),
localCases: JSON.stringify([
{
company: 'TechFlow GmbH',
location: 'Berlin',
result: '40% schnellere Projektlieferung',
quote: 'QuiXzoom Pro veränderte unsere Arbeitsweise'
}
]),
localRegulations: JSON.stringify([
'DSGVO (Datenschutz-Grundverordnung)',
'BDSG (Bundesdatenschutzgesetz)',
'HGB (Handelsgesetzbuch)'
]),
localContacts: JSON.stringify({
supportEmail: 'support@quixzoom.de',
salesEmail: 'vertrieb@quixzoom.de',
phone: '+49-30-12345678',
address: 'Friedrichstraße 171, 10117 Berlin'
}),
localCurrency: 'EUR',
localPricing: JSON.stringify({
starter: '49 €/Monat',
professional: '99 €/Monat',
enterprise: 'Kontaktieren Sie uns für ein Angebot'
}),
translationStatus: 'published' as const,
aiModel: 'gpt-4o-2024-08-06',
sourceVersion: 1,
qualityScore: 94,
seoScore: 90,
canonicalUrl: 'https://quixzoom.de/produkte/quixzoom-pro',
hreflang: 'de-de',
ogTitle: 'QuiXzoom Pro — KI-gestützte Workflow-Optimierung',
ogDescription: 'Transformieren Sie Ihre Geschäftsprozesse mit KI. Starten Sie Ihre kostenlose Testphase.',
publishedDate: new Date()
};
db.insert(translations).values(deTranslation).run();
console.log('Created German translation for QuiXzoom Pro');
}
console.log('Seed completed successfully!');
+24
View File
@@ -0,0 +1,24 @@
/**
* Authentication utilities
*/
import { createHash, randomBytes, timingSafeEqual } from 'crypto';
export async function hash(password: string): Promise<string> {
const salt = randomBytes(16).toString('hex');
const hash = createHash('sha256')
.update(password + salt)
.digest('hex');
return `${salt}:${hash}`;
}
export async function verify(password: string, stored: string): Promise<boolean> {
const [salt, hash] = stored.split(':');
const computed = createHash('sha256')
.update(password + salt)
.digest('hex');
return timingSafeEqual(Buffer.from(hash), Buffer.from(computed));
}
export function generateToken(): string {
return randomBytes(32).toString('hex');
}
+497
View File
@@ -0,0 +1,497 @@
/**
* API Routes for the Global Content Network
* RESTful API for content management, translations, and workflow
*/
import { FastifyInstance } from 'fastify';
import { ZodTypeProvider } from 'fastify-type-provider-zod';
import { z } from 'zod';
import { db } from '../db/index.js';
import { pages, translations, users, translationJobs, changeLog, glossary, seoMetrics } from '../db/schema.js';
import { eq, and, like, desc, sql } from 'drizzle-orm';
import { handleContentChange, queueTranslationJobs, processTranslationJob, applyLocalAdaptation, approveRegionally, publishTranslation, getWorkflowStatus } from '../services/content-workflow.js';
import { generateSeoHeader, runSeoAudit, generateSitemap, generateRobotsTxt } from '../services/seo-service.js';
import { ALL_SITES, getSiteByDomain } from '../config/sites.js';
export async function apiRoutes(fastify: FastifyInstance) {
const app = fastify.withTypeProvider<ZodTypeProvider>();
// === HEALTH ===
app.get('/health', async () => ({
status: 'ok',
timestamp: new Date().toISOString(),
version: '1.0.0'
}));
// === SITES ===
app.get('/sites', async () => {
return ALL_SITES.map(site => ({
domain: site.domain,
brand: site.brand,
tier: site.tier,
languages: site.languages.map(l => ({ code: l.code, name: l.name })),
defaultLanguage: site.defaultLanguage,
features: site.features
}));
});
app.get('/sites/:domain', {
schema: {
params: z.object({ domain: z.string() })
}
}, async (request) => {
const site = getSiteByDomain(request.params.domain);
if (!site) return { error: 'Site not found' };
return site;
});
// === PAGES ===
app.get('/pages', {
schema: {
querystring: z.object({
brand: z.enum(['landvex', 'quixzoom']).optional(),
contentType: z.string().optional(),
status: z.string().optional(),
limit: z.string().transform(Number).default('50'),
offset: z.string().transform(Number).default('0')
})
}
}, async (request) => {
const { brand, contentType, status, limit, offset } = request.query;
let query = db.select().from(pages);
if (brand) query = query.where(eq(pages.brand, brand));
if (contentType) query = query.where(eq(pages.contentType, contentType));
if (status) query = query.where(eq(pages.status, status as any));
const allPages = query.all();
const paginated = allPages.slice(offset, offset + limit);
return {
total: allPages.length,
limit,
offset,
pages: paginated.map(p => ({
id: p.id,
slug: p.slug,
brand: p.brand,
contentType: p.contentType,
title: p.title,
status: p.status,
version: p.version,
needsTranslationUpdate: p.needsTranslationUpdate,
updatedAt: p.updatedAt
}))
};
});
app.get('/pages/:id', {
schema: {
params: z.object({ id: z.string().transform(Number) })
}
}, async (request) => {
const page = db.select().from(pages)
.where(eq(pages.id, request.params.id))
.get();
if (!page) return { error: 'Page not found' };
return page;
});
app.post('/pages', {
schema: {
body: z.object({
slug: z.string(),
brand: z.enum(['landvex', 'quixzoom']),
contentType: z.enum(['page', 'product', 'blog_post', 'faq', 'case_study', 'doc', 'whitepaper', 'glossary', 'landing']),
title: z.string(),
content: z.string(),
metaDescription: z.string().optional(),
metaKeywords: z.string().optional(),
structuredData: z.string().optional(),
canonicalUrl: z.string().optional(),
createdBy: z.number()
})
}
}, async (request) => {
const result = db.insert(pages).values({
...request.body,
sourceDomain: request.body.brand === 'landvex' ? 'landvex.com' : 'quixzoom.com',
sourceLanguage: 'en-US',
status: 'draft',
version: 1,
majorVersion: 1,
minorVersion: 0
}).returning().get();
return result;
});
app.put('/pages/:id', {
schema: {
params: z.object({ id: z.string().transform(Number) }),
body: z.object({
title: z.string().optional(),
content: z.string().optional(),
metaDescription: z.string().optional(),
metaKeywords: z.string().optional(),
structuredData: z.string().optional(),
status: z.enum(['draft', 'review', 'approved', 'published', 'archived']).optional(),
updatedBy: z.number()
})
}
}, async (request) => {
const { id } = request.params;
const { updatedBy, ...updates } = request.body;
// Get current page
const page = db.select().from(pages).where(eq(pages.id, id)).get();
if (!page) return { error: 'Page not found' };
// Update page
db.update(pages)
.set({
...updates,
updatedAt: new Date(),
updatedBy
})
.where(eq(pages.id, id))
.run();
// Handle content change workflow
if (updates.content || updates.title) {
await handleContentChange({
pageId: id,
changedBy: updatedBy,
changeType: 'updated',
changedFields: Object.keys(updates)
});
}
// If published, queue translations
if (updates.status === 'published') {
await queueTranslationJobs(id);
}
return db.select().from(pages).where(eq(pages.id, id)).get();
});
// === TRANSLATIONS ===
app.get('/pages/:id/translations', {
schema: {
params: z.object({ id: z.string().transform(Number) })
}
}, async (request) => {
const pageTranslations = db.select().from(translations)
.where(eq(translations.pageId, request.params.id))
.all();
return pageTranslations;
});
app.get('/translations/:id', {
schema: {
params: z.object({ id: z.string().transform(Number) })
}
}, async (request) => {
const translation = db.select().from(translations)
.where(eq(translations.id, request.params.id))
.get();
if (!translation) return { error: 'Translation not found' };
return translation;
});
app.post('/translations/:id/adapt', {
schema: {
params: z.object({ id: z.string().transform(Number) }),
body: z.object({
adaptedBy: z.number(),
localExamples: z.array(z.string()).optional(),
localCases: z.array(z.object({
company: z.string(),
location: z.string(),
result: z.string(),
quote: z.string().optional()
})).optional(),
localRegulations: z.array(z.string()).optional(),
localContacts: z.record(z.string()).optional(),
localPricing: z.record(z.string()).optional(),
content: z.string().optional()
})
}
}, async (request) => {
await applyLocalAdaptation(request.params.id, request.body.adaptedBy, request.body);
return { success: true };
});
app.post('/translations/:id/approve', {
schema: {
params: z.object({ id: z.string().transform(Number) }),
body: z.object({ approvedBy: z.number() })
}
}, async (request) => {
await approveRegionally(request.params.id, request.body.approvedBy);
return { success: true };
});
app.post('/translations/:id/publish', {
schema: {
params: z.object({ id: z.string().transform(Number) }),
body: z.object({ publishedBy: z.number() })
}
}, async (request) => {
await publishTranslation(request.params.id, request.body.publishedBy);
return { success: true };
});
// === WORKFLOW ===
app.get('/pages/:id/workflow', {
schema: {
params: z.object({ id: z.string().transform(Number) })
}
}, async (request) => {
return getWorkflowStatus(request.params.id);
});
app.post('/pages/:id/translate', {
schema: {
params: z.object({ id: z.string().transform(Number) }),
body: z.object({
languages: z.array(z.string()).optional(),
priority: z.number().default(5)
})
}
}, async (request) => {
const jobIds = await queueTranslationJobs(request.params.id);
return { queued: jobIds.length, jobIds };
});
app.post('/jobs/:id/process', {
schema: {
params: z.object({ id: z.string().transform(Number) })
}
}, async (request) => {
try {
await processTranslationJob(request.params.id);
return { success: true };
} catch (error) {
return { success: false, error: error instanceof Error ? error.message : 'Unknown error' };
}
});
app.get('/jobs', {
schema: {
querystring: z.object({
status: z.string().optional(),
pageId: z.string().transform(Number).optional(),
limit: z.string().transform(Number).default('50')
})
}
}, async (request) => {
let query = db.select().from(translationJobs);
if (request.query.status) {
query = query.where(eq(translationJobs.status, request.query.status as any));
}
if (request.query.pageId) {
query = query.where(eq(translationJobs.pageId, request.query.pageId));
}
return query.all().slice(0, request.query.limit);
});
// === SEO ===
app.get('/pages/:id/seo', {
schema: {
params: z.object({ id: z.string().transform(Number) }),
querystring: z.object({
language: z.string().optional()
})
}
}, async (request) => {
const seoHeader = generateSeoHeader(request.params.id, request.query.language);
return seoHeader;
});
app.post('/pages/:id/seo/audit', {
schema: {
params: z.object({ id: z.string().transform(Number) }),
querystring: z.object({
language: z.string().optional()
})
}
}, async (request) => {
return runSeoAudit(request.params.id, request.query.language);
});
app.get('/sitemap/:domain', {
schema: {
params: z.object({ domain: z.string() })
}
}, async (request, reply) => {
const xml = generateSitemap(request.params.domain);
reply.header('Content-Type', 'application/xml');
return xml;
});
app.get('/robots/:domain', {
schema: {
params: z.object({ domain: z.string() })
}
}, async (request, reply) => {
const txt = generateRobotsTxt(request.params.domain);
reply.header('Content-Type', 'text/plain');
return txt;
});
// === GLOSSARY ===
app.get('/glossary', {
schema: {
querystring: z.object({
brand: z.enum(['landvex', 'quixzoom', 'both']).optional(),
category: z.string().optional(),
language: z.string().optional()
})
}
}, async (request) => {
let query = db.select().from(glossary);
if (request.query.brand) {
query = query.where(eq(glossary.brand, request.query.brand));
}
if (request.query.category) {
query = query.where(eq(glossary.category, request.query.category as any));
}
const entries = query.all();
// Filter by language if specified
if (request.query.language) {
return entries.map(entry => ({
...entry,
translations: JSON.parse(entry.translations),
translation: JSON.parse(entry.translations)[request.query.language!]
}));
}
return entries.map(entry => ({
...entry,
translations: JSON.parse(entry.translations)
}));
});
app.post('/glossary', {
schema: {
body: z.object({
term: z.string(),
brand: z.enum(['landvex', 'quixzoom', 'both']).default('both'),
category: z.enum(['product', 'technical', 'legal', 'marketing', 'support', 'general']).default('general'),
context: z.string().optional(),
translations: z.record(z.string())
})
}
}, async (request) => {
const result = db.insert(glossary).values({
...request.body,
translations: JSON.stringify(request.body.translations),
status: 'draft'
}).returning().get();
return result;
});
// === ANALYTICS ===
app.get('/analytics/translation-status', async () => {
const allPages = db.select().from(pages).all();
const allTranslations = db.select().from(translations).all();
const statusCounts: Record<string, number> = {};
for (const t of allTranslations) {
statusCounts[t.translationStatus] = (statusCounts[t.translationStatus] || 0) + 1;
}
const outdatedCount = allPages.filter(p => p.needsTranslationUpdate).length;
return {
totalPages: allPages.length,
totalTranslations: allTranslations.length,
outdatedPages: outdatedCount,
translationStatusBreakdown: statusCounts,
coverage: {
byLanguage: Object.fromEntries(
Array.from(new Set(allTranslations.map(t => t.targetLanguage))).map(lang => [
lang,
{
total: allTranslations.filter(t => t.targetLanguage === lang).length,
published: allTranslations.filter(t => t.targetLanguage === lang && t.translationStatus === 'published').length
}
])
)
}
};
});
app.get('/analytics/seo-scores', {
schema: {
querystring: z.object({
domain: z.string().optional(),
language: z.string().optional()
})
}
}, async (request) => {
let query = db.select().from(seoMetrics);
if (request.query.domain) {
query = query.where(eq(seoMetrics.domain, request.query.domain));
}
if (request.query.language) {
query = query.where(eq(seoMetrics.language, request.query.language));
}
const metrics = query.all();
const avgScore = metrics.length > 0
? metrics.reduce((sum, m) => sum + (m.seoScore || 0), 0) / metrics.length
: 0;
return {
totalAudits: metrics.length,
averageSeoScore: Math.round(avgScore * 10) / 10,
byDomain: Object.fromEntries(
Array.from(new Set(metrics.map(m => m.domain))).map(domain => [
domain,
{
count: metrics.filter(m => m.domain === domain).length,
avgScore: Math.round(
metrics.filter(m => m.domain === domain).reduce((sum, m) => sum + (m.seoScore || 0), 0) /
metrics.filter(m => m.domain === domain).length * 10
) / 10
}
])
)
};
});
// === CHANGE LOG ===
app.get('/changelog', {
schema: {
querystring: z.object({
entityType: z.string().optional(),
entityId: z.string().transform(Number).optional(),
limit: z.string().transform(Number).default('50')
})
}
}, async (request) => {
let query = db.select().from(changeLog).orderBy(desc(changeLog.performedAt));
if (request.query.entityType) {
query = query.where(eq(changeLog.entityType, request.query.entityType as any));
}
if (request.query.entityId) {
query = query.where(eq(changeLog.entityId, request.query.entityId));
}
return query.all().slice(0, request.query.limit);
});
}
@@ -0,0 +1,87 @@
/**
* Batch Translation Script
* Queue and process translation jobs for all outdated content
*/
import { db } from '../db/index.js';
import { pages, translationJobs } from '../db/schema.js';
import { eq, and } from 'drizzle-orm';
import { queueTranslationJobs, processTranslationJob } from '../services/content-workflow.js';
import { ALL_SITES } from '../config/sites.js';
async function batchTranslate(options: {
brand?: 'landvex' | 'quixzoom';
languages?: string[];
dryRun?: boolean;
processImmediately?: boolean;
} = {}) {
console.log('🚀 Starting batch translation...\n');
// Find pages needing translation
let query = db.select().from(pages)
.where(eq(pages.needsTranslationUpdate, true));
if (options.brand) {
query = query.where(eq(pages.brand, options.brand));
}
const outdatedPages = query.all();
console.log(`Found ${outdatedPages.length} pages needing translation`);
if (options.dryRun) {
console.log('\n📋 DRY RUN - Would queue translations for:');
for (const page of outdatedPages) {
console.log(` - ${page.title} (v${page.version})`);
}
return;
}
// Queue jobs
const allJobIds: number[] = [];
for (const page of outdatedPages) {
const jobIds = await queueTranslationJobs(page.id);
allJobIds.push(...jobIds);
console.log(` ✓ Queued ${jobIds.length} jobs for "${page.title}"`);
}
console.log(`\n📊 Total jobs queued: ${allJobIds.length}`);
// Process immediately if requested
if (options.processImmediately && allJobIds.length > 0) {
console.log('\n⚙️ Processing jobs...\n');
for (let i = 0; i < allJobIds.length; i++) {
const jobId = allJobIds[i];
console.log(` [${i + 1}/${allJobIds.length}] Processing job ${jobId}...`);
try {
await processTranslationJob(jobId);
console.log(` ✓ Completed`);
} catch (err) {
console.log(` ✗ Failed: ${err instanceof Error ? err.message : 'Unknown error'}`);
}
// Rate limiting
if (i < allJobIds.length - 1) {
await new Promise(r => setTimeout(r, 1000));
}
}
}
console.log('\n✅ Batch translation complete!');
}
// CLI
const args = process.argv.slice(2);
const options: {
brand?: 'landvex' | 'quixzoom';
dryRun?: boolean;
processImmediately?: boolean;
} = {};
if (args.includes('--landvex')) options.brand = 'landvex';
if (args.includes('--quixzoom')) options.brand = 'quixzoom';
if (args.includes('--dry-run')) options.dryRun = true;
if (args.includes('--process')) options.processImmediately = true;
batchTranslate(options).catch(console.error);
@@ -0,0 +1,138 @@
/**
* SEO Audit Script
* Runs comprehensive SEO audits across all content
*/
import { db } from '../db/index.js';
import { pages, translations } from '../db/schema.js';
import { eq } from 'drizzle-orm';
import { runSeoAudit, generateSitemap } from '../services/seo-service.js';
import { ALL_SITES } from '../config/sites.js';
import { writeFileSync, mkdirSync } from 'fs';
import { join } from 'path';
async function runSeoAuditBatch(options: {
domain?: string;
language?: string;
generateSitemaps?: boolean;
outputDir?: string;
} = {}) {
console.log('🔍 Starting SEO audit...\n');
const outputDir = options.outputDir || './output/seo';
mkdirSync(outputDir, { recursive: true });
// Audit source pages
const sourcePages = db.select().from(pages)
.where(eq(pages.status, 'published'))
.all();
console.log(`Auditing ${sourcePages.length} source pages...\n`);
const results: Array<{
url: string;
language: string;
score: number;
issues: number;
}> = [];
for (let i = 0; i < sourcePages.length; i++) {
const page = sourcePages[i];
console.log(` [${i + 1}/${sourcePages.length}] ${page.title}`);
try {
// Audit source
const sourceAudit = runSeoAudit(page.id);
results.push({
url: sourceAudit.url,
language: sourceAudit.language,
score: sourceAudit.score,
issues: sourceAudit.issues.length
});
// Audit translations
const pageTranslations = db.select().from(translations)
.where(eq(translations.pageId, page.id))
.all();
for (const translation of pageTranslations) {
if (options.language && translation.targetLanguage !== options.language) continue;
const transAudit = runSeoAudit(page.id, translation.targetLanguage);
results.push({
url: transAudit.url,
language: transAudit.language,
score: transAudit.score,
issues: transAudit.issues.length
});
}
} catch (err) {
console.log(` ✗ Error: ${err instanceof Error ? err.message : 'Unknown'}`);
}
}
// Summary
const avgScore = results.length > 0
? results.reduce((sum, r) => sum + r.score, 0) / results.length
: 0;
const lowScorePages = results.filter(r => r.score < 70);
console.log(`\n📊 SEO Audit Summary`);
console.log(` Total audited: ${results.length}`);
console.log(` Average score: ${Math.round(avgScore * 10) / 10}/100`);
console.log(` Pages needing attention: ${lowScorePages.length}`);
if (lowScorePages.length > 0) {
console.log(`\n⚠️ Low scores:`);
lowScorePages.forEach(r => {
console.log(` - ${r.url} (${r.language}): ${Math.round(r.score)}/100`);
});
}
// Generate sitemaps
if (options.generateSitemaps) {
console.log(`\n🗺️ Generating sitemaps...`);
for (const site of ALL_SITES) {
const sitemap = generateSitemap(site.domain);
const filename = join(outputDir, `sitemap-${site.domain}.xml`);
writeFileSync(filename, sitemap);
console.log(`${filename}`);
}
}
// Save report
const report = {
timestamp: new Date().toISOString(),
summary: {
totalAudited: results.length,
averageScore: Math.round(avgScore * 10) / 10,
needsAttention: lowScorePages.length
},
results
};
const reportFile = join(outputDir, `audit-report-${new Date().toISOString().split('T')[0]}.json`);
writeFileSync(reportFile, JSON.stringify(report, null, 2));
console.log(`\n📝 Report saved: ${reportFile}`);
console.log('\n✅ SEO audit complete!');
}
// CLI
const args = process.argv.slice(2);
const options: {
domain?: string;
language?: string;
generateSitemaps?: boolean;
} = {};
if (args.includes('--sitemaps')) options.generateSitemaps = true;
const domainArg = args.find(a => a.startsWith('--domain='));
if (domainArg) options.domain = domainArg.split('=')[1];
const langArg = args.find(a => a.startsWith('--lang='));
if (langArg) options.language = langArg.split('=')[1];
runSeoAuditBatch(options).catch(console.error);
@@ -0,0 +1,77 @@
/**
* Content Sync Script
* Detects changes in source content and triggers workflow
*/
import { db } from '../db/index.js';
import { pages, changeLog } from '../db/schema.js';
import { eq, gt } from 'drizzle-orm';
import { handleContentChange, queueTranslationJobs } from '../services/content-workflow.js';
async function syncContent(options: {
since?: Date;
brand?: 'landvex' | 'quixzoom';
autoQueue?: boolean;
} = {}) {
console.log('🔄 Starting content sync...\n');
const since = options.since || new Date(Date.now() - 24 * 60 * 60 * 1000); // Last 24h
// Find recently updated pages
let query = db.select().from(pages)
.where(gt(pages.updatedAt, since));
if (options.brand) {
query = query.where(eq(pages.brand, options.brand));
}
const recentPages = query.all();
console.log(`Found ${recentPages.length} pages updated since ${since.toISOString()}`);
for (const page of recentPages) {
console.log(`\n 📄 ${page.title} (v${page.version})`);
console.log(` Updated: ${page.updatedAt.toISOString()}`);
console.log(` Needs translation: ${page.needsTranslationUpdate ? 'YES' : 'No'}`);
if (page.needsTranslationUpdate) {
// Trigger workflow
await handleContentChange({
pageId: page.id,
changedBy: page.updatedBy || 1,
changeType: 'updated',
changedFields: ['content']
});
if (options.autoQueue) {
const jobIds = await queueTranslationJobs(page.id);
console.log(` ✓ Queued ${jobIds.length} translation jobs`);
}
}
}
console.log('\n✅ Content sync complete!');
}
// CLI
const args = process.argv.slice(2);
const options: {
brand?: 'landvex' | 'quixzoom';
autoQueue?: boolean;
hours?: number;
} = {};
if (args.includes('--landvex')) options.brand = 'landvex';
if (args.includes('--quixzoom')) options.brand = 'quixzoom';
if (args.includes('--auto-queue')) options.autoQueue = true;
const hoursArg = args.find(a => a.startsWith('--hours='));
if (hoursArg) {
const hours = parseInt(hoursArg.split('=')[1]);
options.hours = hours;
}
const since = options.hours
? new Date(Date.now() - options.hours * 60 * 60 * 1000)
: undefined;
syncContent({ ...options, since }).catch(console.error);
+85
View File
@@ -0,0 +1,85 @@
/**
* Global Content Network Server
* REST API + Admin Dashboard for Landvex/QuiXzoom Transcreation System
*/
import Fastify from 'fastify';
import cors from '@fastify/cors';
import jwt from '@fastify/jwt';
import staticPlugin from '@fastify/static';
import { apiRoutes } from './routes/api.js';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const app = Fastify({
logger: {
level: process.env.LOG_LEVEL || 'info',
transport: process.env.NODE_ENV === 'development' ? {
target: 'pino-pretty',
options: {
colorize: true,
translateTime: 'HH:MM:ss Z',
ignore: 'pid,hostname'
}
} : undefined
}
});
// Register plugins
await app.register(cors, {
origin: true,
credentials: true
});
await app.register(jwt, {
secret: process.env.JWT_SECRET || 'development-secret-change-in-production'
});
// Static files for admin dashboard
await app.register(staticPlugin, {
root: join(__dirname, '../public'),
prefix: '/'
});
// Authentication hook
app.addHook('onRequest', async (request, reply) => {
// Public paths
const publicPaths = ['/health', '/sites', '/sitemap/', '/robots/', '/login', '/'];
if (publicPaths.some(path => request.url.startsWith(path))) {
return;
}
// Check for auth token
try {
const token = request.headers.authorization?.replace('Bearer ', '');
if (!token) {
throw new Error('No token provided');
}
await request.jwtVerify();
} catch (err) {
reply.code(401).send({ error: 'Unauthorized' });
}
});
// Register API routes
await app.register(apiRoutes, { prefix: '/api' });
// Admin dashboard route
app.get('/', async (request, reply) => {
return reply.sendFile('index.html');
});
// Start server
const port = parseInt(process.env.PORT || '3000');
const host = process.env.HOST || '0.0.0.0';
try {
await app.listen({ port, host });
console.log(`🚀 Global Content Network server running at http://${host}:${port}`);
console.log(`📊 API available at http://${host}:${port}/api`);
console.log(`🌐 Environment: ${process.env.NODE_ENV || 'development'}`);
} catch (err) {
app.log.error(err);
process.exit(1);
}
@@ -0,0 +1,288 @@
/**
* AI Transcreation Service
* Uses OpenAI GPT-4o for intelligent content adaptation
*/
import OpenAI from 'openai';
import { TRANSCREATION_PROMPTS, type SiteConfig, getLanguageConfig } from '../config/sites.js';
import { db } from '../db/index.js';
import { translations, pages, translationJobs } from '../db/schema.js';
import { eq } from 'drizzle-orm';
const openai = new OpenAI({
apiKey: process.env.OPENAI_API_KEY || 'sk-demo'
});
export interface TranscreationRequest {
pageId: number;
targetLanguage: string;
targetDomain: string;
targetSite: SiteConfig;
}
export interface TranscreationResult {
title: string;
metaDescription: string;
metaKeywords: string;
content: string;
structuredData?: string;
localExamples?: string[];
localCases?: Array<{
company: string;
location: string;
result: string;
quote?: string;
}>;
localRegulations?: string[];
localContacts?: Record<string, string>;
localCurrency: string;
localPricing?: Record<string, string>;
qualityScore: number;
tokensUsed: number;
model: string;
}
/**
* Main transcreation function
* Adapts content for target locale using AI + cultural context
*/
export async function transcreateContent(
request: TranscreationRequest
): Promise<TranscreationResult> {
const { pageId, targetLanguage, targetDomain, targetSite } = request;
// Get source content
const page = db.select().from(pages).where(eq(pages.id, pageId)).get();
if (!page) {
throw new Error(`Page ${pageId} not found`);
}
// Get language config
const langConfig = getLanguageConfig(targetSite, targetLanguage);
if (!langConfig) {
throw new Error(`Language ${targetLanguage} not configured for ${targetDomain}`);
}
// Get transcreation prompt
const systemPrompt = TRANSCREATION_PROMPTS[targetLanguage] ||
`You are a professional translator. Translate the content to ${targetLanguage} while maintaining the original meaning and tone.`;
// Get glossary terms for this brand
const glossaryTerms = db.select().from(db.schema.glossary)
.where(eq(db.schema.glossary.brand, page.brand))
.all();
const glossaryContext = glossaryTerms.map(g => {
const translations = JSON.parse(g.translations);
return `- "${g.term}" → ${translations[targetLanguage] || '[translate]'}`;
}).join('\n');
// Build the transcreation prompt
const userPrompt = `TRANSCREATION TASK
Source: ${page.sourceDomain} (${page.sourceLanguage})
Target: ${targetDomain} (${targetLanguage})
Content Type: ${page.contentType}
Brand: ${page.brand}
GLOSSARY (use these approved terms):
${glossaryContext || '(No glossary entries)'}
SOURCE CONTENT:
Title: ${page.title}
Meta Description: ${page.metaDescription || ''}
Meta Keywords: ${page.metaKeywords || ''}
Content:
${page.content}
TASK:
1. Transcreate the title to be culturally relevant and SEO-optimized for ${targetLanguage}
2. Transcreate the meta description (150-160 chars)
3. Transcreate the meta keywords
4. Transcreate the full content, adapting:
- Currency to ${langConfig.currency}
- Date format to ${langConfig.dateFormat}
- Examples to local context
- Case studies to local companies
- Regulations to local laws
- Contact info to local office
- Pricing to local currency
5. Generate local examples (3 items)
6. Generate a local case study
7. List relevant local regulations (3 items)
8. Provide local contact information
9. Provide pricing in local currency
OUTPUT FORMAT (JSON):
{
"title": "...",
"metaDescription": "...",
"metaKeywords": "...",
"content": "markdown content...",
"localExamples": ["...", "...", "..."],
"localCases": [{"company": "...", "location": "...", "result": "...", "quote": "..."}],
"localRegulations": ["...", "...", "..."],
"localContacts": {"supportEmail": "...", "salesEmail": "...", "phone": "...", "address": "..."},
"localCurrency": "...",
"localPricing": {"starter": "...", "professional": "...", "enterprise": "..."}
}`;
// Call OpenAI
const response = await openai.chat.completions.create({
model: 'gpt-4o-2024-08-06',
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userPrompt }
],
response_format: { type: 'json_object' },
temperature: 0.7,
max_tokens: 8000
});
const result = JSON.parse(response.choices[0].message.content || '{}');
const tokensUsed = response.usage?.total_tokens || 0;
// Calculate quality score based on completeness
const qualityScore = calculateQualityScore(result, page);
return {
title: result.title || page.title,
metaDescription: result.metaDescription || page.metaDescription || '',
metaKeywords: result.metaKeywords || page.metaKeywords || '',
content: result.content || page.content,
structuredData: page.structuredData, // Keep same structure, values adapted in content
localExamples: result.localExamples,
localCases: result.localCases,
localRegulations: result.localRegulations,
localContacts: result.localContacts,
localCurrency: result.localCurrency || langConfig.currency,
localPricing: result.localPricing,
qualityScore,
tokensUsed,
model: 'gpt-4o-2024-08-06'
};
}
/**
* Batch transcreate multiple pages
*/
export async function batchTranscreate(
pageIds: number[],
targetLanguage: string,
targetDomain: string,
targetSite: SiteConfig
): Promise<Map<number, TranscreationResult>> {
const results = new Map<number, TranscreationResult>();
for (const pageId of pageIds) {
try {
const result = await transcreateContent({
pageId,
targetLanguage,
targetDomain,
targetSite
});
results.set(pageId, result);
// Small delay to avoid rate limits
await new Promise(r => setTimeout(r, 500));
} catch (error) {
console.error(`Failed to transcreate page ${pageId}:`, error);
}
}
return results;
}
/**
* Save transcreation result to database
*/
export function saveTranscreation(
pageId: number,
targetLanguage: string,
targetDomain: string,
result: TranscreationResult
): void {
const page = db.select().from(pages).where(eq(pages.id, pageId)).get();
if (!page) return;
const existing = db.select().from(translations)
.where(eq(translations.pageId, pageId))
.all()
.find(t => t.targetLanguage === targetLanguage);
const translationData = {
pageId,
targetDomain,
targetLanguage,
title: result.title,
metaDescription: result.metaDescription,
metaKeywords: result.metaKeywords,
content: result.content,
structuredData: result.structuredData,
localExamples: result.localExamples ? JSON.stringify(result.localExamples) : null,
localCases: result.localCases ? JSON.stringify(result.localCases) : null,
localRegulations: result.localRegulations ? JSON.stringify(result.localRegulations) : null,
localContacts: result.localContacts ? JSON.stringify(result.localContacts) : null,
localCurrency: result.localCurrency,
localPricing: result.localPricing ? JSON.stringify(result.localPricing) : null,
translationStatus: 'ai_translated' as const,
aiModel: result.model,
aiTranslationDate: new Date(),
sourceVersion: page.version,
qualityScore: result.qualityScore,
seoScore: result.qualityScore * 0.95, // Slightly lower until human review
hreflang: targetLanguage.toLowerCase().replace('-', '-'),
canonicalUrl: `https://${targetDomain}/${page.slug}`,
ogTitle: result.title,
ogDescription: result.metaDescription,
updatedAt: new Date()
};
if (existing) {
db.update(translations)
.set({
...translationData,
translationVersion: existing.translationVersion + 1
})
.where(eq(translations.id, existing.id))
.run();
} else {
db.insert(translations).values(translationData).run();
}
// Update page translation status
db.update(pages)
.set({
needsTranslationUpdate: false,
lastTranslationSync: new Date()
})
.where(eq(pages.id, pageId))
.run();
}
/**
* Calculate quality score based on result completeness
*/
function calculateQualityScore(result: any, sourcePage: any): number {
let score = 70; // Base score
// Check completeness
if (result.title && result.title !== sourcePage.title) score += 5;
if (result.metaDescription && result.metaDescription.length > 50) score += 5;
if (result.content && result.content.length > sourcePage.content.length * 0.8) score += 5;
if (result.localExamples && result.localExamples.length > 0) score += 5;
if (result.localCases && result.localCases.length > 0) score += 5;
if (result.localRegulations && result.localRegulations.length > 0) score += 5;
return Math.min(score, 100);
}
/**
* Estimate translation cost
*/
export function estimateCost(tokens: number): number {
// GPT-4o pricing: $5 per 1M input tokens, $15 per 1M output tokens
// Average: ~$10 per 1M tokens
return (tokens / 1_000_000) * 10;
}
@@ -0,0 +1,373 @@
/**
* Content Workflow Service
* Manages the translation lifecycle when source content changes
*/
import { db } from '../db/index.js';
import { pages, translations, translationJobs, changeLog } from '../db/schema.js';
import { eq, and, inArray } from 'drizzle-orm';
import { getSiteByDomain, ALL_SITES, TRANSLATION_TARGETS } from '../config/sites.js';
export interface WorkflowStep {
step: number;
name: string;
status: 'pending' | 'in_progress' | 'completed' | 'failed';
startedAt?: Date;
completedAt?: Date;
}
export interface ContentChangeEvent {
pageId: number;
changedBy: number;
changeType: 'created' | 'updated' | 'published';
changedFields: string[];
}
/**
* Handle content change - mark translations as outdated
* Step 1 of workflow
*/
export async function handleContentChange(event: ContentChangeEvent): Promise<void> {
const { pageId, changedBy, changeType } = event;
// Get the page
const page = db.select().from(pages).where(eq(pages.id, pageId)).get();
if (!page) return;
// Increment version
const newVersion = page.version + 1;
const newMinorVersion = changeType === 'updated' ? page.minorVersion + 1 : 0;
const newMajorVersion = changeType === 'published' ? page.majorVersion + 1 : page.majorVersion;
// Update page
db.update(pages)
.set({
version: newVersion,
majorVersion: newMajorVersion,
minorVersion: newMinorVersion,
needsTranslationUpdate: true,
updatedAt: new Date(),
updatedBy: changedBy
})
.where(eq(pages.id, pageId))
.run();
// Mark all translations as outdated
const existingTranslations = db.select().from(translations)
.where(eq(translations.pageId, pageId))
.all();
for (const translation of existingTranslations) {
db.update(translations)
.set({
translationStatus: 'outdated',
updatedAt: new Date()
})
.where(eq(translations.id, translation.id))
.run();
}
// Log the change
db.insert(changeLog).values({
entityType: 'page',
entityId: pageId,
action: changeType === 'created' ? 'created' : 'updated',
performedBy: changedBy,
performedAt: new Date()
}).run();
console.log(`Content change handled for page ${pageId}. Translations marked as outdated.`);
}
/**
* Queue AI translation jobs
* Step 2 of workflow
*/
export async function queueTranslationJobs(pageId: number): Promise<number[]> {
const page = db.select().from(pages).where(eq(pages.id, pageId)).get();
if (!page) return [];
const jobIds: number[] = [];
// Determine target languages based on brand
const brandSites = ALL_SITES.filter(s => s.brand === page.brand && s.tier !== 'global');
for (const site of brandSites) {
for (const language of site.languages) {
// Skip if already up to date
const existing = db.select().from(translations)
.where(and(
eq(translations.pageId, pageId),
eq(translations.targetLanguage, language.code)
))
.get();
if (existing && existing.sourceVersion === page.version) {
continue; // Already current
}
// Create or update translation job
const existingJob = db.select().from(translationJobs)
.where(and(
eq(translationJobs.pageId, pageId),
eq(translationJobs.targetLanguage, language.code)
))
.all()
.find(j => j.status === 'queued' || j.status === 'processing');
if (!existingJob) {
const result = db.insert(translationJobs).values({
pageId,
targetLanguage: language.code,
targetDomain: site.domain,
status: 'queued',
priority: getPriority(page.contentType)
}).returning().get();
jobIds.push(result.id);
}
}
}
console.log(`Queued ${jobIds.length} translation jobs for page ${pageId}`);
return jobIds;
}
/**
* Process translation job (called by worker)
*/
export async function processTranslationJob(jobId: number): Promise<void> {
const job = db.select().from(translationJobs)
.where(eq(translationJobs.id, jobId))
.get();
if (!job || job.status !== 'queued') return;
// Mark as processing
db.update(translationJobs)
.set({
status: 'processing',
startedAt: new Date()
})
.where(eq(translationJobs.id, jobId))
.run();
try {
// Import AI service dynamically to avoid circular deps
const { transcreateContent, saveTranscreation } = await import('./ai-transcreation.js');
const { getSiteByDomain } = await import('../config/sites.js');
const site = getSiteByDomain(job.targetDomain);
if (!site) {
throw new Error(`Site ${job.targetDomain} not found`);
}
// Perform transcreation
const result = await transcreateContent({
pageId: job.pageId,
targetLanguage: job.targetLanguage,
targetDomain: job.targetDomain,
targetSite: site
});
// Save result
saveTranscreation(job.pageId, job.targetLanguage, job.targetDomain, result);
// Mark job as completed
db.update(translationJobs)
.set({
status: 'completed',
completedAt: new Date(),
aiModel: result.model,
tokensUsed: result.tokensUsed,
costEstimate: ((result.tokensUsed || 0) / 1_000_000) * 10
})
.where(eq(translationJobs.id, jobId))
.run();
console.log(`Translation job ${jobId} completed successfully`);
} catch (error) {
// Mark job as failed
db.update(translationJobs)
.set({
status: 'failed',
completedAt: new Date(),
errorMessage: error instanceof Error ? error.message : 'Unknown error'
})
.where(eq(translationJobs.id, jobId))
.run();
console.error(`Translation job ${jobId} failed:`, error);
throw error;
}
}
/**
* Apply local adaptation
* Step 3 of workflow
*/
export async function applyLocalAdaptation(
translationId: number,
adaptedBy: number,
adaptations: {
localExamples?: string[];
localCases?: Array<{ company: string; location: string; result: string; quote?: string }>;
localRegulations?: string[];
localContacts?: Record<string, string>;
localPricing?: Record<string, string>;
content?: string;
}
): Promise<void> {
const translation = db.select().from(translations)
.where(eq(translations.id, translationId))
.get();
if (!translation) return;
db.update(translations)
.set({
translationStatus: 'locally_adapted',
adaptedBy,
localAdaptationDate: new Date(),
localExamples: adaptations.localExamples ? JSON.stringify(adaptations.localExamples) : translation.localExamples,
localCases: adaptations.localCases ? JSON.stringify(adaptations.localCases) : translation.localCases,
localRegulations: adaptations.localRegulations ? JSON.stringify(adaptations.localRegulations) : translation.localRegulations,
localContacts: adaptations.localContacts ? JSON.stringify(adaptations.localContacts) : translation.localContacts,
localPricing: adaptations.localPricing ? JSON.stringify(adaptations.localPricing) : translation.localPricing,
content: adaptations.content || translation.content,
updatedAt: new Date()
})
.where(eq(translations.id, translationId))
.run();
// Log
db.insert(changeLog).values({
entityType: 'translation',
entityId: translationId,
action: 'updated',
fieldName: 'local_adaptation',
performedBy: adaptedBy,
performedAt: new Date(),
languageCode: translation.targetLanguage,
domain: translation.targetDomain
}).run();
}
/**
* Regional approval
* Step 4 of workflow
*/
export async function approveRegionally(
translationId: number,
approvedBy: number
): Promise<void> {
const translation = db.select().from(translations)
.where(eq(translations.id, translationId))
.get();
if (!translation) return;
db.update(translations)
.set({
translationStatus: 'regional_approved',
approvedBy,
regionalApprovalDate: new Date(),
updatedAt: new Date()
})
.where(eq(translations.id, translationId))
.run();
// Log
db.insert(changeLog).values({
entityType: 'translation',
entityId: translationId,
action: 'approved',
performedBy: approvedBy,
performedAt: new Date(),
languageCode: translation.targetLanguage,
domain: translation.targetDomain
}).run();
}
/**
* Publish translation
* Step 5 of workflow
*/
export async function publishTranslation(
translationId: number,
publishedBy: number
): Promise<void> {
const translation = db.select().from(translations)
.where(eq(translations.id, translationId))
.get();
if (!translation) return;
db.update(translations)
.set({
translationStatus: 'published',
publishedDate: new Date(),
updatedAt: new Date()
})
.where(eq(translations.id, translationId))
.run();
// Log
db.insert(changeLog).values({
entityType: 'translation',
entityId: translationId,
action: 'published',
performedBy: publishedBy,
performedAt: new Date(),
languageCode: translation.targetLanguage,
domain: translation.targetDomain
}).run();
}
/**
* Get workflow status for a page
*/
export function getWorkflowStatus(pageId: number): {
page: typeof pages.$inferSelect | undefined;
translations: Array<{
language: string;
domain: string;
status: string;
version: number;
lastUpdated: Date;
}>;
} {
const page = db.select().from(pages).where(eq(pages.id, pageId)).get();
const pageTranslations = db.select().from(translations)
.where(eq(translations.pageId, pageId))
.all();
return {
page,
translations: pageTranslations.map(t => ({
language: t.targetLanguage,
domain: t.targetDomain,
status: t.translationStatus,
version: t.translationVersion,
lastUpdated: t.updatedAt
}))
};
}
/**
* Get priority based on content type
*/
function getPriority(contentType: string): number {
const priorities: Record<string, number> = {
product: 1,
landing: 2,
page: 3,
blog_post: 5,
faq: 6,
case_study: 7,
doc: 8,
whitepaper: 9,
glossary: 10
};
return priorities[contentType] || 5;
}
@@ -0,0 +1,636 @@
/**
* SEO Service
* Manages hreflang tags, canonical URLs, structured data, and SEO metrics
*/
import { db } from '../db/index.js';
import { pages, translations, seoMetrics } from '../db/schema.js';
import { eq, and } from 'drizzle-orm';
import { ALL_SITES, getSiteByDomain } from '../config/sites.js';
export interface HreflangEntry {
url: string;
language: string;
region?: string;
}
export interface SeoAuditResult {
url: string;
language: string;
domain: string;
score: number;
issues: Array<{
severity: 'error' | 'warning' | 'info';
field: string;
message: string;
recommendation: string;
}>;
metrics: {
titleLength: number;
metaDescriptionLength: number;
wordCount: number;
hasCanonical: boolean;
hasHreflang: boolean;
hasStructuredData: boolean;
hasOgTags: boolean;
};
}
/**
* Generate hreflang tags for a page across all locales
*/
export function generateHreflangTags(pageId: number): HreflangEntry[] {
const page = db.select().from(pages).where(eq(pages.id, pageId)).get();
if (!page) return [];
const entries: HreflangEntry[] = [];
// Add source (x-default and en-US)
entries.push({
url: `https://${page.sourceDomain}/${page.slug}`,
language: 'x-default'
});
entries.push({
url: `https://${page.sourceDomain}/${page.slug}`,
language: 'en-us'
});
// Add all translations
const pageTranslations = db.select().from(translations)
.where(eq(translations.pageId, pageId))
.all();
for (const translation of pageTranslations) {
if (translation.translationStatus === 'published') {
entries.push({
url: translation.canonicalUrl || `https://${translation.targetDomain}/${page.slug}`,
language: translation.hreflang || translation.targetLanguage.toLowerCase().replace('-', '-')
});
}
}
return entries;
}
/**
* Generate hreflang HTML tags
*/
export function generateHreflangHtml(pageId: number): string {
const entries = generateHreflangTags(pageId);
return entries.map(entry =>
`<link rel="alternate" hreflang="${entry.language}" href="${entry.url}" />`
).join('\n');
}
/**
* Generate canonical tag
*/
export function generateCanonicalTag(
pageId: number,
language?: string
): string {
const page = db.select().from(pages).where(eq(pages.id, pageId)).get();
if (!page) return '';
if (language && language !== 'en-US') {
const translation = db.select().from(translations)
.where(and(
eq(translations.pageId, pageId),
eq(translations.targetLanguage, language)
))
.get();
if (translation?.canonicalUrl) {
return `<link rel="canonical" href="${translation.canonicalUrl}" />`;
}
}
return `<link rel="canonical" href="${page.canonicalUrl || `https://${page.sourceDomain}/${page.slug}`}" />`;
}
/**
* Generate Schema.org JSON-LD
*/
export function generateStructuredData(
pageId: number,
language?: string
): string {
const page = db.select().from(pages).where(eq(pages.id, pageId)).get();
if (!page) return '';
let structuredData: any;
if (language && language !== 'en-US') {
const translation = db.select().from(translations)
.where(and(
eq(translations.pageId, pageId),
eq(translations.targetLanguage, language)
))
.get();
if (translation?.structuredData) {
structuredData = JSON.parse(translation.structuredData);
}
}
if (!structuredData && page.structuredData) {
structuredData = JSON.parse(page.structuredData);
}
if (!structuredData) {
// Generate default WebPage schema
structuredData = {
'@context': 'https://schema.org',
'@type': 'WebPage',
name: page.title,
description: page.metaDescription || '',
url: page.canonicalUrl || `https://${page.sourceDomain}/${page.slug}`,
inLanguage: language || 'en-US'
};
}
// Add hreflang references
const hreflangEntries = generateHreflangTags(pageId);
if (hreflangEntries.length > 1) {
structuredData.workTranslation = hreflangEntries.map(entry => ({
'@type': 'WebPage',
inLanguage: entry.language,
url: entry.url
}));
}
return JSON.stringify(structuredData, null, 2);
}
/**
* Generate Open Graph tags
*/
export function generateOpenGraphTags(
pageId: number,
language?: string
): Record<string, string> {
const page = db.select().from(pages).where(eq(pages.id, pageId)).get();
if (!page) return {};
let title = page.ogTitle || page.title;
let description = page.ogDescription || page.metaDescription || '';
let image = page.ogImage || page.featuredImage || '';
if (language && language !== 'en-US') {
const translation = db.select().from(translations)
.where(and(
eq(translations.pageId, pageId),
eq(translations.targetLanguage, language)
))
.get();
if (translation) {
title = translation.ogTitle || translation.title || title;
description = translation.ogDescription || translation.metaDescription || description;
image = translation.ogImage || image;
}
}
return {
'og:title': title,
'og:description': description,
'og:image': image,
'og:type': 'website',
'og:locale': language?.replace('-', '_') || 'en_US'
};
}
/**
* Generate Twitter Card tags
*/
export function generateTwitterCardTags(
pageId: number,
language?: string
): Record<string, string> {
const ogTags = generateOpenGraphTags(pageId, language);
return {
'twitter:card': 'summary_large_image',
'twitter:title': ogTags['og:title'],
'twitter:description': ogTags['og:description'],
'twitter:image': ogTags['og:image']
};
}
/**
* Generate complete SEO header block
*/
export function generateSeoHeader(
pageId: number,
language?: string
): {
title: string;
metaDescription: string;
canonical: string;
hreflang: string;
structuredData: string;
openGraph: Record<string, string>;
twitterCard: Record<string, string>;
} {
const page = db.select().from(pages).where(eq(pages.id, pageId)).get();
if (!page) {
return {
title: '',
metaDescription: '',
canonical: '',
hreflang: '',
structuredData: '',
openGraph: {},
twitterCard: {}
};
}
let title = page.title;
let metaDescription = page.metaDescription || '';
if (language && language !== 'en-US') {
const translation = db.select().from(translations)
.where(and(
eq(translations.pageId, pageId),
eq(translations.targetLanguage, language)
))
.get();
if (translation) {
title = translation.title;
metaDescription = translation.metaDescription || metaDescription;
}
}
return {
title,
metaDescription,
canonical: generateCanonicalTag(pageId, language),
hreflang: generateHreflangHtml(pageId),
structuredData: generateStructuredData(pageId, language),
openGraph: generateOpenGraphTags(pageId, language),
twitterCard: generateTwitterCardTags(pageId, language)
};
}
/**
* Run SEO audit on a page/translation
*/
export function runSeoAudit(
pageId: number,
language?: string
): SeoAuditResult {
const page = db.select().from(pages).where(eq(pages.id, pageId)).get();
if (!page) {
throw new Error(`Page ${pageId} not found`);
}
let content = page.content;
let title = page.title;
let metaDescription = page.metaDescription || '';
let domain = page.sourceDomain;
let url = page.canonicalUrl || `https://${domain}/${page.slug}`;
if (language && language !== 'en-US') {
const translation = db.select().from(translations)
.where(and(
eq(translations.pageId, pageId),
eq(translations.targetLanguage, language)
))
.get();
if (translation) {
content = translation.content;
title = translation.title;
metaDescription = translation.metaDescription || metaDescription;
domain = translation.targetDomain;
url = translation.canonicalUrl || url;
}
}
const issues: SeoAuditResult['issues'] = [];
let score = 100;
// Title checks
const titleLength = title.length;
if (titleLength < 30) {
issues.push({
severity: 'warning',
field: 'title',
message: `Title is too short (${titleLength} chars)`,
recommendation: 'Use 50-60 characters for optimal display'
});
score -= 10;
} else if (titleLength > 60) {
issues.push({
severity: 'warning',
field: 'title',
message: `Title is too long (${titleLength} chars)`,
recommendation: 'Keep under 60 characters to avoid truncation'
});
score -= 5;
}
// Meta description checks
const metaDescLength = metaDescription.length;
if (metaDescLength < 120) {
issues.push({
severity: 'warning',
field: 'metaDescription',
message: `Meta description is too short (${metaDescLength} chars)`,
recommendation: 'Use 150-160 characters for optimal display'
});
score -= 10;
} else if (metaDescLength > 160) {
issues.push({
severity: 'warning',
field: 'metaDescription',
message: `Meta description is too long (${metaDescLength} chars)`,
recommendation: 'Keep under 160 characters'
});
score -= 5;
}
// Content checks
const wordCount = content.split(/\s+/).length;
if (wordCount < 300) {
issues.push({
severity: 'info',
field: 'content',
message: `Content is relatively short (${wordCount} words)`,
recommendation: 'Aim for 500+ words for better SEO performance'
});
score -= 5;
}
// Heading checks
const h1Count = (content.match(/^# /gm) || []).length;
const h2Count = (content.match(/^## /gm) || []).length;
if (h1Count === 0) {
issues.push({
severity: 'error',
field: 'headings',
message: 'No H1 heading found',
recommendation: 'Add exactly one H1 heading'
});
score -= 15;
} else if (h1Count > 1) {
issues.push({
severity: 'error',
field: 'headings',
message: `Multiple H1 headings found (${h1Count})`,
recommendation: 'Use only one H1 per page'
});
score -= 10;
}
if (h2Count === 0) {
issues.push({
severity: 'warning',
field: 'headings',
message: 'No H2 headings found',
recommendation: 'Use H2 headings to structure content'
});
score -= 5;
}
// Link checks
const internalLinks = (content.match(/\[.*?\]\((?!http).*?\)/g) || []).length;
const externalLinks = (content.match(/\[.*?\]\(https?:\/\//g) || []).length;
if (internalLinks < 2) {
issues.push({
severity: 'info',
field: 'links',
message: 'Few internal links',
recommendation: 'Add more internal links for better navigation'
});
score -= 3;
}
// Image checks
const images = content.match(/!\[.*?\]\(.*?\)/g) || [];
const imagesWithAlt = images.filter(img => !img.includes('![](')).length;
if (images.length > 0 && imagesWithAlt < images.length) {
issues.push({
severity: 'warning',
field: 'images',
message: `${images.length - imagesWithAlt} images missing alt text`,
recommendation: 'Add descriptive alt text to all images'
});
score -= 5;
}
// Canonical check
const hasCanonical = !!page.canonicalUrl;
if (!hasCanonical) {
issues.push({
severity: 'error',
field: 'canonical',
message: 'Missing canonical URL',
recommendation: 'Add canonical URL to prevent duplicate content issues'
});
score -= 10;
}
// Hreflang check
const hreflangEntries = generateHreflangTags(pageId);
const hasHreflang = hreflangEntries.length > 1;
// Structured data check
const hasStructuredData = !!page.structuredData;
if (!hasStructuredData) {
issues.push({
severity: 'info',
field: 'structuredData',
message: 'Missing structured data',
recommendation: 'Add Schema.org structured data for rich snippets'
});
score -= 5;
}
// OG tags check
const ogTags = generateOpenGraphTags(pageId, language);
const hasOgTags = !!ogTags['og:title'] && !!ogTags['og:description'];
if (!hasOgTags) {
issues.push({
severity: 'warning',
field: 'openGraph',
message: 'Incomplete Open Graph tags',
recommendation: 'Add og:title and og:description for social sharing'
});
score -= 5;
}
// Save metrics
db.insert(seoMetrics).values({
pageId,
domain,
language: language || 'en-US',
url,
titleLength,
metaDescriptionLength: metaDescLength,
h1Count,
h2Count,
wordCount,
internalLinks,
externalLinks,
imageCount: images.length,
imagesWithAlt,
hasCanonical: hasCanonical ? 1 : 0,
hasHreflang: hasHreflang ? 1 : 0,
hasStructuredData: hasStructuredData ? 1 : 0,
hasOgTags: hasOgTags ? 1 : 0,
seoScore: Math.max(0, score),
readabilityScore: calculateReadability(content),
keywordDensity: calculateKeywordDensity(content, title)
}).run();
return {
url,
language: language || 'en-US',
domain,
score: Math.max(0, score),
issues,
metrics: {
titleLength,
metaDescriptionLength: metaDescLength,
wordCount,
hasCanonical,
hasHreflang,
hasStructuredData,
hasOgTags
}
};
}
/**
* Calculate Flesch Reading Ease score
*/
function calculateReadability(content: string): number {
const sentences = content.split(/[.!?]+/).filter(s => s.trim().length > 0);
const words = content.split(/\s+/).filter(w => w.length > 0);
const syllables = words.reduce((count, word) => {
return count + Math.max(1, word.toLowerCase().split(/[aeiouy]+/).length - 1);
}, 0);
if (sentences.length === 0 || words.length === 0) return 0;
const avgSentenceLength = words.length / sentences.length;
const avgSyllablesPerWord = syllables / words.length;
// Flesch Reading Ease
return 206.835 - (1.015 * avgSentenceLength) - (84.6 * avgSyllablesPerWord);
}
/**
* Calculate keyword density
*/
function calculateKeywordDensity(content: string, title: string): number {
const words = content.toLowerCase().split(/\s+/);
const titleWords = title.toLowerCase().split(/\s+/).filter(w => w.length > 3);
if (titleWords.length === 0 || words.length === 0) return 0;
const keywordCounts = titleWords.map(keyword =>
words.filter(w => w.includes(keyword)).length
);
const totalKeywordOccurrences = keywordCounts.reduce((a, b) => a + b, 0);
return (totalKeywordOccurrences / words.length) * 100;
}
/**
* Generate sitemap XML for a domain
*/
export function generateSitemap(domain: string): string {
const site = getSiteByDomain(domain);
if (!site) return '';
const brandPages = db.select().from(pages)
.where(eq(pages.brand, site.brand))
.all()
.filter(p => p.status === 'published');
const urls: Array<{
loc: string;
lastmod: string;
changefreq: string;
priority: string;
alternates?: Array<{ hreflang: string; href: string }>;
}> = [];
for (const page of brandPages) {
const hreflangEntries = generateHreflangTags(page.id);
if (site.tier === 'global') {
urls.push({
loc: `https://${domain}/${page.slug}`,
lastmod: page.updatedAt.toISOString().split('T')[0],
changefreq: page.contentType === 'blog_post' ? 'weekly' : 'monthly',
priority: page.contentType === 'product' ? '1.0' : '0.8',
alternates: hreflangEntries
});
} else {
// For national/regional sites, add translated URLs
const translation = db.select().from(translations)
.where(and(
eq(translations.pageId, page.id),
eq(translations.targetDomain, domain)
))
.get();
if (translation && translation.translationStatus === 'published') {
urls.push({
loc: translation.canonicalUrl || `https://${domain}/${page.slug}`,
lastmod: translation.updatedAt.toISOString().split('T')[0],
changefreq: page.contentType === 'blog_post' ? 'weekly' : 'monthly',
priority: page.contentType === 'product' ? '1.0' : '0.8',
alternates: hreflangEntries
});
}
}
}
// Generate XML
let xml = '<?xml version="1.0" encoding="UTF-8"?>\n';
xml += '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"\n';
xml += ' xmlns:xhtml="http://www.w3.org/1999/xhtml">\n';
for (const url of urls) {
xml += ' <url>\n';
xml += ` <loc>${url.loc}</loc>\n`;
xml += ` <lastmod>${url.lastmod}</lastmod>\n`;
xml += ` <changefreq>${url.changefreq}</changefreq>\n`;
xml += ` <priority>${url.priority}</priority>\n`;
if (url.alternates) {
for (const alt of url.alternates) {
xml += ` <xhtml:link rel="alternate" hreflang="${alt.hreflang}" href="${alt.href}" />\n`;
}
}
xml += ' </url>\n';
}
xml += '</urlset>';
return xml;
}
/**
* Generate robots.txt
*/
export function generateRobotsTxt(domain: string): string {
return `User-agent: *
Allow: /
Sitemap: https://${domain}/sitemap.xml
# Disallow admin paths
Disallow: /admin/
Disallow: /api/
Disallow: /internal/
`;
}