import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'; import { dirname, join } from 'path'; import { PlatformGraphqlService } from '../platform/platform-graphql.service'; import { DEFAULT_SETUP_STATE, SETUP_STEP_NAMES, type SetupState, type SetupStepName, } from './setup-state.defaults'; const SETUP_STATE_PATH = join(process.cwd(), 'data', 'setup-state.json'); // File-backed store for the onboarding wizard's progress. Mirrors the // pattern of ThemeService and WidgetConfigService — load on init, cache in // memory, write on every change. No backups (the data is small and easily // recreated by the wizard if it ever gets corrupted). // // Workspace scoping: the sidecar's API key is scoped to exactly one // workspace, so on first access we compare the file's stored workspaceId // against the live currentWorkspace.id from the platform. If they differ // (DB reset, re-onboard, sidecar pointed at a new workspace), the file is // reset before any reads return. This guarantees a fresh wizard for a // fresh workspace without manual file deletion. @Injectable() export class SetupStateService implements OnModuleInit { private readonly logger = new Logger(SetupStateService.name); private cached: SetupState | null = null; // Memoize the platform's currentWorkspace.id lookup so we don't hit // the platform on every getState() call. Set once per process boot // (or after a successful reset). private liveWorkspaceId: string | null = null; private workspaceCheckPromise: Promise | null = null; constructor(private platform: PlatformGraphqlService) {} onModuleInit() { this.load(); // Fire-and-forget the workspace probe so the file gets aligned // before the frontend's first GET. Errors are logged but // non-fatal — if the platform is down at boot, the legacy // unscoped behaviour kicks in until the first reachable probe. this.ensureWorkspaceMatch().catch((err) => this.logger.warn(`Initial workspace probe failed: ${err}`), ); } getState(): SetupState { if (this.cached) return this.cached; return this.load(); } // Awaits a workspace check before returning state. The controller // calls this so the GET response always reflects the current // workspace, not yesterday's. async getStateChecked(): Promise { await this.ensureWorkspaceMatch(); return this.getState(); } private async ensureWorkspaceMatch(): Promise { // Single-flight: if a check is already running, await it. if (this.workspaceCheckPromise) return this.workspaceCheckPromise; if (this.liveWorkspaceId) { // Already validated this process. Trust the cache. return; } this.workspaceCheckPromise = (async () => { try { const data = await this.platform.query<{ currentWorkspace: { id: string }; }>(`{ currentWorkspace { id } }`); const liveId = data?.currentWorkspace?.id ?? null; if (!liveId) { this.logger.warn( 'currentWorkspace.id was empty — cannot scope setup-state', ); return; } this.liveWorkspaceId = liveId; const current = this.getState(); if (current.workspaceId && current.workspaceId !== liveId) { this.logger.log( `Workspace changed (${current.workspaceId} → ${liveId}) — resetting setup-state`, ); this.resetState(); } if (!current.workspaceId) { // First boot after the workspaceId field was added // (or first boot ever). Stamp the file so future // boots can detect drift. const stamped: SetupState = { ...this.getState(), workspaceId: liveId, }; this.writeFile(stamped); this.cached = stamped; } } finally { this.workspaceCheckPromise = null; } })(); return this.workspaceCheckPromise; } // Returns true if any required step is incomplete and the wizard hasn't // been explicitly dismissed. Used by the frontend post-login redirect. isWizardRequired(): boolean { const s = this.getState(); if (s.wizardDismissed) return false; return SETUP_STEP_NAMES.some(name => !s.steps[name].completed); } markStepCompleted(step: SetupStepName, completedBy: string | null = null): SetupState { const current = this.getState(); if (!current.steps[step]) { throw new Error(`Unknown setup step: ${step}`); } const updated: SetupState = { ...current, steps: { ...current.steps, [step]: { completed: true, completedAt: new Date().toISOString(), completedBy, }, }, version: (current.version ?? 0) + 1, updatedAt: new Date().toISOString(), }; this.writeFile(updated); this.cached = updated; this.logger.log(`Setup step '${step}' marked completed`); return updated; } markStepIncomplete(step: SetupStepName): SetupState { const current = this.getState(); if (!current.steps[step]) { throw new Error(`Unknown setup step: ${step}`); } const updated: SetupState = { ...current, steps: { ...current.steps, [step]: { completed: false, completedAt: null, completedBy: null }, }, version: (current.version ?? 0) + 1, updatedAt: new Date().toISOString(), }; this.writeFile(updated); this.cached = updated; this.logger.log(`Setup step '${step}' marked incomplete`); return updated; } dismissWizard(): SetupState { const current = this.getState(); const updated: SetupState = { ...current, wizardDismissed: true, version: (current.version ?? 0) + 1, updatedAt: new Date().toISOString(), }; this.writeFile(updated); this.cached = updated; this.logger.log('Setup wizard dismissed'); return updated; } resetState(): SetupState { // Preserve the live workspaceId on reset so the file remains // scoped — otherwise the next workspace check would think the // file is unscoped and re-stamp it, which is fine but creates // an extra write. const fresh: SetupState = { ...DEFAULT_SETUP_STATE, workspaceId: this.liveWorkspaceId ?? null, }; this.writeFile(fresh); this.cached = fresh; this.logger.log('Setup state reset to defaults'); return this.cached; } private load(): SetupState { try { if (existsSync(SETUP_STATE_PATH)) { const raw = readFileSync(SETUP_STATE_PATH, 'utf8'); const parsed = JSON.parse(raw); // Defensive merge: if a new step name is added later, the old // file won't have it. Fill missing steps with the empty default. const merged: SetupState = { ...DEFAULT_SETUP_STATE, ...parsed, steps: { ...DEFAULT_SETUP_STATE.steps, ...(parsed.steps ?? {}), }, }; this.cached = merged; this.logger.log('Setup state loaded from file'); return merged; } } catch (err) { this.logger.warn(`Failed to load setup state: ${err}`); } const fresh: SetupState = JSON.parse(JSON.stringify(DEFAULT_SETUP_STATE)); this.cached = fresh; this.logger.log('Using default setup state (no file yet)'); return fresh; } private writeFile(state: SetupState) { const dir = dirname(SETUP_STATE_PATH); if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); writeFileSync(SETUP_STATE_PATH, JSON.stringify(state, null, 2), 'utf8'); } }