+
+
+
+
+
+
+
+ {/if}
+
+ {#if data.totalPages > 1}
+
+ {/if}
+
+ {#snippet desktopActions(r: LabResultItem)}
+
+
+
+
+
+ {#if r.flag}
+
{r.flag}
+ {/if}
+
+
{r.collected_at.slice(0, 10)}
+
+
+ {formatValue(r)}
+
+ {#if r.lab}
+
{r.lab}
+ {/if}
+ {@render mobileActions(r)}
+
+
@@ -134,72 +659,47 @@
{m["labResults_colValue"]()}
{m["labResults_colFlag"]()}
{m["labResults_colLab"]()}
+ {m["labResults_colActions"]()}
- {#each data.results as r (r.record_id)}
+ {#each displayGroups as group (group.key)}
+ {#if group.label}
+
+
+
+ {group.label}
+
+
+
+ {/if}
+ {#each group.items as r (r.record_id)}
+ {@render desktopRow(r)}
+ {/each}
+ {/each}
+ {#if data.resultPage.items.length === 0}
- {r.collected_at.slice(0, 10)}
- {r.test_name_original ?? r.test_code}
- {r.test_code}
-
- {#if r.value_num != null}
- {r.value_num} {r.unit_original ?? ''}
- {:else if r.value_text}
- {r.value_text}
- {:else}
- —
- {/if}
-
-
- {#if r.flag}
-
- {r.flag}
-
- {:else}
- —
- {/if}
-
- {r.lab ?? '—'}
-
- {:else}
-
-
+
{m["labResults_noResults"]()}
- {/each}
+ {/if}
-
- {#each data.results as r (r.record_id)}
-
-
- {r.test_name_original ?? r.test_code}
- {#if r.flag}
-
- {r.flag}
-
- {/if}
-
-
{r.collected_at.slice(0, 10)}
-
- {r.test_code}
- {#if r.value_num != null}
- {r.value_num} {r.unit_original ?? ''}
- {:else if r.value_text}
- {r.value_text}
- {/if}
-
- {#if r.lab}
-
{r.lab}
- {/if}
-
- {:else}
-
{m["labResults_noResults"]()}
+
+ {#each displayGroups as group (group.key)}
+ {#if group.label}
+
{group.label}
+ {/if}
+ {#each group.items as r (r.record_id)}
+ {@render mobileCard(r)}
+ {/each}
{/each}
+ {#if data.resultPage.items.length === 0}
+
{m["labResults_noResults"]()}
+ {/if}
diff --git a/frontend/src/routes/health/medications/+page.svelte b/frontend/src/routes/health/medications/+page.svelte
index 1b791f1..f34be71 100644
--- a/frontend/src/routes/health/medications/+page.svelte
+++ b/frontend/src/routes/health/medications/+page.svelte
@@ -1,6 +1,5 @@
+
+
{m.profile_title()} — innercontext
+
+
+
+ {m["nav_appSubtitle"]()}
+ {m.profile_title()}
+ {m.profile_subtitle()}
+
+
+ {#if form?.error}
+
{form.error}
+ {/if}
+ {#if form?.saved}
+
{m.profile_saved()}
+ {/if}
+
+
+
diff --git a/frontend/src/routes/routines/+page.svelte b/frontend/src/routes/routines/+page.svelte
index b72c3bc..730f1f8 100644
--- a/frontend/src/routes/routines/+page.svelte
+++ b/frontend/src/routes/routines/+page.svelte
@@ -4,6 +4,7 @@
import { m } from '$lib/paraglide/messages.js';
import { Badge } from '$lib/components/ui/badge';
import { Button } from '$lib/components/ui/button';
+ import { Sparkles } from 'lucide-svelte';
let { data }: { data: PageData } = $props();
@@ -28,7 +29,7 @@
{m.routines_title()}
{m.routines_count({ count: data.routines.length })}
-
+
diff --git a/frontend/src/routes/routines/[id]/+page.server.ts b/frontend/src/routes/routines/[id]/+page.server.ts
index 73dcbdf..426a4b4 100644
--- a/frontend/src/routes/routines/[id]/+page.server.ts
+++ b/frontend/src/routes/routines/[id]/+page.server.ts
@@ -1,10 +1,10 @@
-import { addRoutineStep, deleteRoutine, deleteRoutineStep, getProducts, getRoutine } from '$lib/api';
+import { addRoutineStep, deleteRoutine, deleteRoutineStep, getProductSummaries, getRoutine } from '$lib/api';
import { error, fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ params }) => {
try {
- const [routine, products] = await Promise.all([getRoutine(params.id), getProducts()]);
+ const [routine, products] = await Promise.all([getRoutine(params.id), getProductSummaries()]);
return { routine, products };
} catch {
error(404, 'Routine not found');
diff --git a/frontend/src/routes/routines/[id]/+page.svelte b/frontend/src/routes/routines/[id]/+page.svelte
index ee8e846..a80c9d7 100644
--- a/frontend/src/routes/routines/[id]/+page.svelte
+++ b/frontend/src/routes/routines/[id]/+page.svelte
@@ -149,7 +149,7 @@
Routine {routine.routine_date} {routine.part_of_day.toUpperCase()} — innercontext
-
+
{m["routines_backToList"]()}
{m["nav_appSubtitle"]()}
diff --git a/frontend/src/routes/routines/grooming-schedule/+page.svelte b/frontend/src/routes/routines/grooming-schedule/+page.svelte
index 869bceb..2050166 100644
--- a/frontend/src/routes/routines/grooming-schedule/+page.svelte
+++ b/frontend/src/routes/routines/grooming-schedule/+page.svelte
@@ -48,17 +48,15 @@
{m.grooming_title()} — innercontext
-
-
-
-
{m["grooming_backToRoutines"]()}
-
{m["nav_appSubtitle"]()}
-
{m.grooming_title()}
+
+ {m["grooming_backToRoutines"]()}
+ {m["nav_appSubtitle"]()}
+ {m.grooming_title()}
+
+
-
-
{#if form?.error}
diff --git a/frontend/src/routes/routines/new/+page.svelte b/frontend/src/routes/routines/new/+page.svelte
index 2246fd2..f4996e4 100644
--- a/frontend/src/routes/routines/new/+page.svelte
+++ b/frontend/src/routes/routines/new/+page.svelte
@@ -22,7 +22,7 @@
{m["routines_newTitle"]()} — innercontext
-
+
{m["routines_backToList"]()}
{m["nav_appSubtitle"]()}
{m["routines_newTitle"]()}
diff --git a/frontend/src/routes/routines/suggest/+page.server.ts b/frontend/src/routes/routines/suggest/+page.server.ts
index 99fd7ac..49d645c 100644
--- a/frontend/src/routes/routines/suggest/+page.server.ts
+++ b/frontend/src/routes/routines/suggest/+page.server.ts
@@ -1,9 +1,9 @@
-import { addRoutineStep, createRoutine, getProducts, suggestBatch, suggestRoutine } from '$lib/api';
+import { addRoutineStep, createRoutine, getProductSummaries, suggestBatch, suggestRoutine } from '$lib/api';
import { fail, redirect } from '@sveltejs/kit';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async () => {
- const products = await getProducts();
+ const products = await getProductSummaries();
const today = new Date().toISOString().slice(0, 10);
return { products, today };
};
@@ -76,7 +76,6 @@ export const actions: Actions = {
product_id?: string;
action_type?: string;
action_notes?: string;
- dose?: string;
region?: string;
}>;
try {
@@ -96,7 +95,6 @@ export const actions: Actions = {
product_id: s.product_id || undefined,
action_type: s.action_type || undefined,
action_notes: s.action_notes || undefined,
- dose: s.dose || undefined,
region: s.region || undefined
});
}
@@ -120,14 +118,12 @@ export const actions: Actions = {
product_id?: string;
action_type?: string;
action_notes?: string;
- dose?: string;
region?: string;
}>;
pm_steps: Array<{
product_id?: string;
action_type?: string;
action_notes?: string;
- dose?: string;
region?: string;
}>;
}>;
@@ -150,7 +146,6 @@ export const actions: Actions = {
product_id: s.product_id || undefined,
action_type: s.action_type || undefined,
action_notes: s.action_notes || undefined,
- dose: s.dose || undefined,
region: s.region || undefined
});
}
diff --git a/frontend/src/routes/routines/suggest/+page.svelte b/frontend/src/routes/routines/suggest/+page.svelte
index ad1f741..e9ec526 100644
--- a/frontend/src/routes/routines/suggest/+page.svelte
+++ b/frontend/src/routes/routines/suggest/+page.svelte
@@ -15,7 +15,12 @@
import HintCheckbox from '$lib/components/forms/HintCheckbox.svelte';
import SimpleSelect from '$lib/components/forms/SimpleSelect.svelte';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '$lib/components/ui/tabs';
- import { ChevronUp, ChevronDown, ArrowLeft } from 'lucide-svelte';
+ import { ChevronUp, ChevronDown, ArrowLeft, Sparkles } from 'lucide-svelte';
+ import ValidationWarningsAlert from '$lib/components/ValidationWarningsAlert.svelte';
+ import StructuredErrorDisplay from '$lib/components/StructuredErrorDisplay.svelte';
+ import AutoFixBadge from '$lib/components/AutoFixBadge.svelte';
+ import ReasoningChainViewer from '$lib/components/ReasoningChainViewer.svelte';
+ import MetadataDebugPanel from '$lib/components/MetadataDebugPanel.svelte';
let { data }: { data: PageData } = $props();
@@ -55,7 +60,6 @@
function stepMeta(step: SuggestedStep): string {
const parts: string[] = [];
- if (step.dose) parts.push(step.dose);
if (step.region && step.region.toLowerCase() !== 'face') parts.push(step.region);
if (step.action_notes && !step.action_type) parts.push(step.action_notes);
return parts.join(' · ');
@@ -115,14 +119,14 @@
{m.suggest_title()} — innercontext
-
+
{#if errorMsg}
- {errorMsg}
+
{/if}
@@ -179,7 +183,7 @@
{m.suggest_generating()}
{:else}
- {m["suggest_generateBtn"]()}
+ {m["suggest_generateBtn"]()}
{/if}
@@ -216,6 +220,20 @@
{/if}
+
+ {#if suggestion.auto_fixes_applied}
+
+ {/if}
+ {#if suggestion.validation_warnings}
+
+ {/if}
+ {#if suggestion.response_metadata?.reasoning_chain}
+
+ {/if}
+ {#if suggestion.response_metadata}
+
+ {/if}
+
{#each suggestion.steps as step, i (i)}
@@ -255,7 +273,7 @@
{/if}
@@ -305,7 +323,7 @@
{m["suggest_generatingPlan"]()}
{:else}
- {m["suggest_generatePlan"]()}
+ {m["suggest_generatePlan"]()}
{/if}
@@ -324,6 +342,20 @@
{/if}
+
+ {#if batch.auto_fixes_applied}
+
+ {/if}
+ {#if batch.validation_warnings}
+
+ {/if}
+ {#if batch.response_metadata?.reasoning_chain}
+
+ {/if}
+ {#if batch.response_metadata}
+
+ {/if}
+
{#each batch.days as day (day.date)}
@@ -420,7 +452,7 @@
{/if}
diff --git a/frontend/src/routes/skin/+page.server.ts b/frontend/src/routes/skin/+page.server.ts
index 90a1401..504e76f 100644
--- a/frontend/src/routes/skin/+page.server.ts
+++ b/frontend/src/routes/skin/+page.server.ts
@@ -18,17 +18,17 @@ export const actions: Actions = {
const hydration_level = form.get('hydration_level') as string;
const sensitivity_level = form.get('sensitivity_level') as string;
const barrier_state = form.get('barrier_state') as string;
- const active_concerns_raw = form.get('active_concerns') as string;
+ const active_concerns_values = form
+ .getAll('active_concerns')
+ .map((value) => String(value).trim())
+ .filter(Boolean);
const priorities_raw = form.get('priorities') as string;
if (!snapshot_date) {
return fail(400, { error: 'Date is required' });
}
- const active_concerns = active_concerns_raw
- ?.split(',')
- .map((c) => c.trim())
- .filter(Boolean) ?? [];
+ const active_concerns = active_concerns_values;
const priorities = priorities_raw
?.split(',')
@@ -68,7 +68,10 @@ export const actions: Actions = {
const hydration_level = form.get('hydration_level') as string;
const sensitivity_level = form.get('sensitivity_level') as string;
const barrier_state = form.get('barrier_state') as string;
- const active_concerns_raw = form.get('active_concerns') as string;
+ const active_concerns_values = form
+ .getAll('active_concerns')
+ .map((value) => String(value).trim())
+ .filter(Boolean);
const priorities_raw = form.get('priorities') as string;
const skin_type = form.get('skin_type') as string;
const sebum_tzone = form.get('sebum_tzone') as string;
@@ -76,10 +79,7 @@ export const actions: Actions = {
if (!id) return fail(400, { error: 'Missing id' });
- const active_concerns = active_concerns_raw
- ?.split(',')
- .map((c) => c.trim())
- .filter(Boolean) ?? [];
+ const active_concerns = active_concerns_values;
const priorities = priorities_raw
?.split(',')
diff --git a/frontend/src/routes/skin/+page.svelte b/frontend/src/routes/skin/+page.svelte
index 5a386bb..2922c56 100644
--- a/frontend/src/routes/skin/+page.svelte
+++ b/frontend/src/routes/skin/+page.svelte
@@ -16,6 +16,18 @@
const skinTextures = ['smooth', 'rough', 'flaky', 'bumpy'];
const barrierStates = ['intact', 'mildly_compromised', 'compromised'];
const skinTypes = ['dry', 'oily', 'combination', 'sensitive', 'normal', 'acne_prone'];
+ const activeConcernValues = [
+ 'acne',
+ 'rosacea',
+ 'hyperpigmentation',
+ 'aging',
+ 'dehydration',
+ 'redness',
+ 'damaged_barrier',
+ 'pore_visibility',
+ 'uneven_texture',
+ 'sebum_excess'
+ ];
const statePills: Record = {
excellent: 'state-pill state-pill--excellent',
@@ -53,6 +65,20 @@
acne_prone: m["skin_typeAcneProne"]
};
+ const concernLabels: Record string> = {
+ acne: m["productForm_concernAcne"],
+ rosacea: m["productForm_concernRosacea"],
+ hyperpigmentation: m["productForm_concernHyperpigmentation"],
+ aging: m["productForm_concernAging"],
+ dehydration: m["productForm_concernDehydration"],
+ redness: m["productForm_concernRedness"],
+ damaged_barrier: m["productForm_concernDamagedBarrier"],
+ pore_visibility: m["productForm_concernPoreVisibility"],
+ uneven_texture: m["productForm_concernUnevenTexture"],
+ hair_growth: m["productForm_concernHairGrowth"],
+ sebum_excess: m["productForm_concernSebumExcess"]
+ };
+
let showForm = $state(false);
// Create form state (bound to inputs so AI can pre-fill)
@@ -65,7 +91,7 @@
let sensitivityLevel = $state('');
let sebumTzone = $state('');
let sebumCheeks = $state('');
- let activeConcernsRaw = $state('');
+ let activeConcerns = $state([]);
let prioritiesRaw = $state('');
let notes = $state('');
@@ -80,7 +106,7 @@
let editSensitivityLevel = $state('');
let editSebumTzone = $state('');
let editSebumCheeks = $state('');
- let editActiveConcernsRaw = $state('');
+ let editActiveConcerns = $state([]);
let editPrioritiesRaw = $state('');
let editNotes = $state('');
@@ -95,7 +121,7 @@
editSensitivityLevel = snap.sensitivity_level != null ? String(snap.sensitivity_level) : '';
editSebumTzone = snap.sebum_tzone != null ? String(snap.sebum_tzone) : '';
editSebumCheeks = snap.sebum_cheeks != null ? String(snap.sebum_cheeks) : '';
- editActiveConcernsRaw = snap.active_concerns?.join(', ') ?? '';
+ editActiveConcerns = [...(snap.active_concerns ?? [])];
editPrioritiesRaw = snap.priorities?.join(', ') ?? '';
editNotes = snap.notes ?? '';
showForm = false;
@@ -112,6 +138,12 @@
const textureOptions = $derived(skinTextures.map((t) => ({ value: t, label: textureLabels[t]?.() ?? t })));
const skinTypeOptions = $derived(skinTypes.map((st) => ({ value: st, label: skinTypeLabels[st]?.() ?? st })));
const barrierOptions = $derived(barrierStates.map((b) => ({ value: b, label: barrierLabels[b]?.() ?? b })));
+ const activeConcernOptions = $derived(
+ activeConcernValues.map((value) => ({
+ value,
+ label: concernLabels[value]?.() ?? value.replace(/_/g, ' ')
+ }))
+ );
const sortedSnapshots = $derived(
[...data.snapshots].sort((a, b) => b.snapshot_date.localeCompare(a.snapshot_date))
@@ -139,7 +171,7 @@
if (r.sensitivity_level != null) sensitivityLevel = String(r.sensitivity_level);
if (r.sebum_tzone != null) sebumTzone = String(r.sebum_tzone);
if (r.sebum_cheeks != null) sebumCheeks = String(r.sebum_cheeks);
- if (r.active_concerns?.length) activeConcernsRaw = r.active_concerns.join(', ');
+ if (r.active_concerns?.length) activeConcerns = [...r.active_concerns];
if (r.priorities?.length) prioritiesRaw = r.priorities.join(', ');
if (r.notes) notes = r.notes;
aiModalOpen = false;
@@ -332,14 +364,30 @@
max="5"
bind:value={sebumCheeks}
/>
-
+
+
{m["skin_activeConcerns"]()}
+
+ {#each activeConcernOptions as option (option.value)}
+
+ {/each}
+
+
-
+
+
{m["skin_activeConcerns"]()}
+
+ {#each activeConcernOptions as option (option.value)}
+
+ {/each}
+
+
{#each snap.active_concerns as c (c)}
- {c.replace(/_/g, ' ')}
+ {concernLabels[c]?.() ?? c.replace(/_/g, ' ')}
{/each}
{/if}
diff --git a/opencode.json b/opencode.json
new file mode 100644
index 0000000..f6ec424
--- /dev/null
+++ b/opencode.json
@@ -0,0 +1,12 @@
+{
+ "$schema": "https://opencode.ai/config.json",
+ "mcp": {
+ "Playwright": {
+ "type": "local",
+ "command": [
+ "npx",
+ "@playwright/mcp@latest"
+ ]
+ }
+ }
+}
\ No newline at end of file
diff --git a/scripts/backup-database.sh b/scripts/backup-database.sh
new file mode 100755
index 0000000..d44b078
--- /dev/null
+++ b/scripts/backup-database.sh
@@ -0,0 +1,59 @@
+#!/bin/bash
+#
+# Database backup script for innercontext PostgreSQL database
+# Should be run daily via cron on the PostgreSQL LXC:
+# 0 2 * * * /opt/innercontext/scripts/backup-database.sh >> /opt/innercontext/backup.log 2>&1
+#
+# Note: This script should be copied to the PostgreSQL LXC container
+# and run there (not on the app LXC)
+#
+
+set -euo pipefail
+
+# Configuration
+BACKUP_DIR="/opt/innercontext/backups"
+DB_NAME="innercontext"
+DB_USER="innercontext"
+KEEP_DAYS=7
+TIMESTAMP=$(date '+%Y%m%d_%H%M%S')
+BACKUP_FILE="$BACKUP_DIR/innercontext_${TIMESTAMP}.sql.gz"
+
+# Color codes
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+NC='\033[0m' # No Color
+
+log() {
+ echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1"
+}
+
+# Create backup directory if it doesn't exist
+mkdir -p "$BACKUP_DIR"
+
+# Create backup
+log "Starting database backup..."
+if pg_dump -U "$DB_USER" -d "$DB_NAME" | gzip > "$BACKUP_FILE"; then
+ BACKUP_SIZE=$(du -h "$BACKUP_FILE" | cut -f1)
+ log "${GREEN}✓${NC} Backup created: $BACKUP_FILE ($BACKUP_SIZE)"
+else
+ log "${RED}✗${NC} Backup failed"
+ exit 1
+fi
+
+# Clean up old backups
+log "Cleaning up backups older than $KEEP_DAYS days..."
+find "$BACKUP_DIR" -name "innercontext_*.sql.gz" -type f -mtime +$KEEP_DAYS -delete
+REMAINING=$(find "$BACKUP_DIR" -name "innercontext_*.sql.gz" -type f | wc -l)
+log "${GREEN}✓${NC} Cleanup complete. $REMAINING backup(s) remaining"
+
+# Verify backup can be read
+if gunzip -t "$BACKUP_FILE" 2>/dev/null; then
+ log "${GREEN}✓${NC} Backup integrity verified"
+else
+ log "${RED}✗${NC} Backup integrity check failed"
+ exit 1
+fi
+
+log "${GREEN}✓${NC} Database backup completed successfully"
+exit 0
diff --git a/scripts/healthcheck.sh b/scripts/healthcheck.sh
new file mode 100755
index 0000000..9d370ff
--- /dev/null
+++ b/scripts/healthcheck.sh
@@ -0,0 +1,66 @@
+#!/bin/bash
+#
+# Health check script for innercontext services
+# Should be run via cron every 5 minutes:
+# */5 * * * * /opt/innercontext/scripts/healthcheck.sh >> /opt/innercontext/healthcheck.log 2>&1
+#
+
+set -euo pipefail
+
+BACKEND_URL="http://127.0.0.1:8000/health-check"
+FRONTEND_URL="http://127.0.0.1:3000/"
+TIMEOUT=10
+TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S')
+
+# Color codes
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+NC='\033[0m' # No Color
+
+log() {
+ echo "[$TIMESTAMP] $1"
+}
+
+check_service() {
+ local service_name=$1
+ local url=$2
+
+ if systemctl is-active --quiet "$service_name"; then
+ if curl -sf --max-time "$TIMEOUT" "$url" > /dev/null 2>&1; then
+ log "${GREEN}✓${NC} $service_name is healthy"
+ return 0
+ else
+ log "${YELLOW}⚠${NC} $service_name is running but not responding at $url"
+ return 1
+ fi
+ else
+ log "${RED}✗${NC} $service_name is not running"
+ return 1
+ fi
+}
+
+# Check all services
+backend_ok=0
+frontend_ok=0
+worker_ok=0
+
+check_service "innercontext" "$BACKEND_URL" || backend_ok=1
+check_service "innercontext-node" "$FRONTEND_URL" || frontend_ok=1
+
+# Worker doesn't have HTTP endpoint, just check if it's running
+if systemctl is-active --quiet "innercontext-pricing-worker"; then
+ log "${GREEN}✓${NC} innercontext-pricing-worker is running"
+else
+ log "${RED}✗${NC} innercontext-pricing-worker is not running"
+ worker_ok=1
+fi
+
+# If any service is unhealthy, exit with error code
+if [ $backend_ok -ne 0 ] || [ $frontend_ok -ne 0 ] || [ $worker_ok -ne 0 ]; then
+ log "${RED}Health check failed${NC}"
+ exit 1
+else
+ log "${GREEN}All services healthy${NC}"
+ exit 0
+fi
diff --git a/scripts/validate-env.sh b/scripts/validate-env.sh
new file mode 100755
index 0000000..3ca90a4
--- /dev/null
+++ b/scripts/validate-env.sh
@@ -0,0 +1,157 @@
+#!/bin/bash
+#
+# Validate environment variables for innercontext deployment
+# Checks both shared directory (persistent config) and current release (symlinks)
+#
+
+set -euo pipefail
+
+# Color codes
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+NC='\033[0m' # No Color
+
+# Shared directory (persistent configuration)
+SHARED_BACKEND_ENV="/opt/innercontext/shared/backend/.env"
+SHARED_FRONTEND_ENV="/opt/innercontext/shared/frontend/.env.production"
+
+# Current release (should be symlinks to shared)
+CURRENT_BACKEND_ENV="/opt/innercontext/current/backend/.env"
+CURRENT_FRONTEND_ENV="/opt/innercontext/current/frontend/.env.production"
+
+errors=0
+warnings=0
+
+log_error() {
+ echo -e "${RED}✗${NC} $1"
+ ((errors++))
+}
+
+log_success() {
+ echo -e "${GREEN}✓${NC} $1"
+}
+
+log_warning() {
+ echo -e "${YELLOW}⚠${NC} $1"
+ ((warnings++))
+}
+
+check_symlink() {
+ local symlink_path=$1
+ local expected_target=$2
+
+ if [ ! -L "$symlink_path" ]; then
+ log_error "Not a symlink: $symlink_path"
+ return 1
+ fi
+
+ local actual_target=$(readlink "$symlink_path")
+ if [ "$actual_target" != "$expected_target" ]; then
+ log_warning "Symlink target mismatch: $symlink_path -> $actual_target (expected: $expected_target)"
+ else
+ log_success "Symlink correct: $symlink_path -> $actual_target"
+ fi
+}
+
+check_var() {
+ local file=$1
+ local var_name=$2
+ local optional=${3:-false}
+
+ if [ ! -f "$file" ]; then
+ log_error "File not found: $file"
+ return 1
+ fi
+
+ # Check if variable exists and is not empty
+ if grep -q "^${var_name}=" "$file"; then
+ local value=$(grep "^${var_name}=" "$file" | cut -d'=' -f2-)
+ if [ -z "$value" ]; then
+ if [ "$optional" = true ]; then
+ log_warning "$var_name is empty in $file (optional)"
+ else
+ log_error "$var_name is empty in $file"
+ fi
+ else
+ log_success "$var_name is set"
+ fi
+ else
+ if [ "$optional" = true ]; then
+ log_warning "$var_name not found in $file (optional)"
+ else
+ log_error "$var_name not found in $file"
+ fi
+ fi
+}
+
+echo "=== Validating Shared Directory Structure ==="
+
+# Check shared directory exists
+if [ -d "/opt/innercontext/shared" ]; then
+ log_success "Shared directory exists: /opt/innercontext/shared"
+else
+ log_error "Shared directory not found: /opt/innercontext/shared"
+fi
+
+# Check shared backend .env
+if [ -f "$SHARED_BACKEND_ENV" ]; then
+ log_success "Shared backend .env exists: $SHARED_BACKEND_ENV"
+else
+ log_error "Shared backend .env not found: $SHARED_BACKEND_ENV"
+fi
+
+# Check shared frontend .env.production
+if [ -f "$SHARED_FRONTEND_ENV" ]; then
+ log_success "Shared frontend .env.production exists: $SHARED_FRONTEND_ENV"
+else
+ log_error "Shared frontend .env.production not found: $SHARED_FRONTEND_ENV"
+fi
+
+echo ""
+echo "=== Validating Symlinks in Current Release ==="
+
+# Check current release symlinks point to shared directory
+if [ -e "$CURRENT_BACKEND_ENV" ]; then
+ check_symlink "$CURRENT_BACKEND_ENV" "../../../shared/backend/.env"
+else
+ log_error "Current backend .env not found: $CURRENT_BACKEND_ENV"
+fi
+
+if [ -e "$CURRENT_FRONTEND_ENV" ]; then
+ check_symlink "$CURRENT_FRONTEND_ENV" "../../../shared/frontend/.env.production"
+else
+ log_error "Current frontend .env.production not found: $CURRENT_FRONTEND_ENV"
+fi
+
+echo ""
+echo "=== Validating Backend Environment Variables ==="
+if [ -f "$SHARED_BACKEND_ENV" ]; then
+ check_var "$SHARED_BACKEND_ENV" "DATABASE_URL"
+ check_var "$SHARED_BACKEND_ENV" "GEMINI_API_KEY"
+ check_var "$SHARED_BACKEND_ENV" "LOG_LEVEL" true
+ check_var "$SHARED_BACKEND_ENV" "CORS_ORIGINS" true
+fi
+
+echo ""
+echo "=== Validating Frontend Environment Variables ==="
+if [ -f "$SHARED_FRONTEND_ENV" ]; then
+ check_var "$SHARED_FRONTEND_ENV" "PUBLIC_API_BASE"
+ check_var "$SHARED_FRONTEND_ENV" "ORIGIN"
+fi
+
+echo ""
+if [ $errors -eq 0 ]; then
+ if [ $warnings -eq 0 ]; then
+ echo -e "${GREEN}✓ All environment checks passed${NC}"
+ else
+ echo -e "${YELLOW}⚠ Environment validation passed with $warnings warning(s)${NC}"
+ fi
+ exit 0
+else
+ echo -e "${RED}✗ Found $errors error(s) in environment configuration${NC}"
+ if [ $warnings -gt 0 ]; then
+ echo -e "${YELLOW} And $warnings warning(s)${NC}"
+ fi
+ exit 1
+fi
diff --git a/skills-lock.json b/skills-lock.json
new file mode 100644
index 0000000..083626b
--- /dev/null
+++ b/skills-lock.json
@@ -0,0 +1,25 @@
+{
+ "version": 1,
+ "skills": {
+ "conventional-commit": {
+ "source": "github/awesome-copilot",
+ "sourceType": "github",
+ "computedHash": "f29c9486cede6c7b2df0cfb0a2e4aa67446552b991bcf17d1b309e903171f03d"
+ },
+ "frontend-design": {
+ "source": "anthropics/skills",
+ "sourceType": "github",
+ "computedHash": "063a0e6448123cd359ad0044cc46b0e490cc7964d45ef4bb9fd842bd2ffbca67"
+ },
+ "gemini-api-dev": {
+ "source": "google-gemini/gemini-skills",
+ "sourceType": "github",
+ "computedHash": "8ff8d320f21bff9dc980b8249743f39df11c80e07da3b2c5ab070d8ea9bdab46"
+ },
+ "svelte-code-writer": {
+ "source": "sveltejs/mcp",
+ "sourceType": "github",
+ "computedHash": "c0e2cce9855f8e312cbb0a05aef164b4659c672d7723e4e598ffa6bc94890542"
+ }
+ }
+}
diff --git a/systemd/innercontext-pricing-worker.service b/systemd/innercontext-pricing-worker.service
new file mode 100644
index 0000000..8d84648
--- /dev/null
+++ b/systemd/innercontext-pricing-worker.service
@@ -0,0 +1,16 @@
+[Unit]
+Description=innercontext async pricing worker
+After=network.target
+
+[Service]
+Type=simple
+User=innercontext
+Group=innercontext
+WorkingDirectory=/opt/innercontext/backend
+EnvironmentFile=/opt/innercontext/backend/.env
+ExecStart=/opt/innercontext/backend/.venv/bin/python -m innercontext.workers.pricing
+Restart=on-failure
+RestartSec=5
+
+[Install]
+WantedBy=multi-user.target