diff --git a/lib/offline-storage.ts b/lib/offline-storage.ts index c286227..008eb4e 100644 --- a/lib/offline-storage.ts +++ b/lib/offline-storage.ts @@ -1,12 +1,14 @@ import { interviewTemplates, type InterviewTemplate } from "@/data/interview-templates" import { mockQuestions } from "@/data/mock-questions" -import type { InterviewSession, InterviewQuestion } from "@/types/interview" +import type { InterviewSession, InterviewQuestion, UserQuestionSet, QuestionBankSyncState } from "@/types/interview" const STORAGE_KEYS = { INTERVIEW_SESSIONS: 'interview-sessions', CACHED_QUESTIONS: 'cached-questions', TEMPLATES: 'interview-templates', USER_PREFERENCES: 'user-preferences', + USER_QUESTION_SETS: 'user-question-sets', + USER_QUESTION_SET_SYNC_STATE: 'user-question-set-sync-state', } as const export interface StoredSession { @@ -30,6 +32,18 @@ export interface UserPreferences { theme: 'light' | 'dark' | 'system' } +export type CachedUserQuestionSet = UserQuestionSet + +type UserQuestionSetSyncState = QuestionBankSyncState + +type UserQuestionSetLike = Omit & { + tags?: string[] + questions?: InterviewQuestion[] + createdAt?: string + updatedAt?: string + difficulty?: UserQuestionSet['difficulty'] +} + export class OfflineStorage { private static instance: OfflineStorage @@ -61,6 +75,66 @@ export class OfflineStorage { } } + private normalizeInterviewQuestion( + question: InterviewQuestion, + setId?: string, + fallbackTags: string[] = [] + ): InterviewQuestion { + const followUpValue = question.followUp + const normalizedFollowUp = Array.isArray(followUpValue) + ? followUpValue.filter((item): item is string => typeof item === 'string') + : typeof followUpValue === 'string' + ? [followUpValue] + : [] + + const tagsValue = (Array.isArray(question.tags) ? question.tags : fallbackTags) + .filter((tag): tag is string => typeof tag === 'string') + + return { + ...question, + followUp: normalizedFollowUp.length > 0 ? normalizedFollowUp : undefined, + origin: question.origin ?? (setId ? 'user' : 'default'), + setId: setId ?? question.setId, + tags: tagsValue + } + } + + private normalizeUserQuestionSet(set: UserQuestionSetLike): CachedUserQuestionSet { + const tags = Array.isArray(set.tags) + ? set.tags.filter((tag): tag is string => typeof tag === 'string') + : [] + + const createdAt = set.createdAt ?? new Date().toISOString() + const updatedAt = set.updatedAt ?? createdAt + const allowedDifficulties: Array = ['easy', 'medium', 'hard', 'mixed'] + const difficulty = set.difficulty && allowedDifficulties.includes(set.difficulty) + ? set.difficulty + : 'medium' + + const questions = (set.questions ?? []).map(question => + this.normalizeInterviewQuestion(question, set.id, tags) + ) + + return { + id: set.id, + title: set.title, + industry: set.industry ?? null, + tags, + difficulty, + createdAt, + updatedAt, + questions + } + } + + private normalizeSyncState(state?: QuestionBankSyncState | null): UserQuestionSetSyncState { + if (!state || !state.lastSyncedAt) { + return { lastSyncedAt: null } + } + + return { lastSyncedAt: state.lastSyncedAt } + } + // Interview Sessions public saveSession(session: StoredSession): void { const sessions = this.getAllSessions() @@ -104,8 +178,12 @@ export class OfflineStorage { public cacheQuestions(questions: InterviewQuestion[]): void { const existing = this.getCachedQuestions() const merged = [...existing] - - questions.forEach(newQuestion => { + + const normalizedIncoming = questions.map(question => + this.normalizeInterviewQuestion(question, question.setId) + ) + + normalizedIncoming.forEach(newQuestion => { const existingIndex = merged.findIndex(q => q.id === newQuestion.id) if (existingIndex >= 0) { merged[existingIndex] = newQuestion @@ -113,19 +191,131 @@ export class OfflineStorage { merged.push(newQuestion) } }) - + this.setItem(STORAGE_KEYS.CACHED_QUESTIONS, merged) } public getCachedQuestions(): InterviewQuestion[] { const cached = this.getItem(STORAGE_KEYS.CACHED_QUESTIONS) - return cached || mockQuestions + if (cached && cached.length > 0) { + return cached.map(question => this.normalizeInterviewQuestion(question, question.setId)) + } + + return mockQuestions.map(question => this.normalizeInterviewQuestion(question)) } public addQuestionsToCache(questions: InterviewQuestion[]): void { this.cacheQuestions(questions) } + // User Question Sets + public getCachedUserQuestionSets(): CachedUserQuestionSet[] { + const cached = this.getItem(STORAGE_KEYS.USER_QUESTION_SETS) + if (!cached) { + return [] + } + + return cached.map(set => this.normalizeUserQuestionSet(set)) + } + + public syncUserQuestionSets(sets: UserQuestionSetLike[], syncedAt?: string): void { + const normalized = sets.map(set => this.normalizeUserQuestionSet(set)) + this.setItem(STORAGE_KEYS.USER_QUESTION_SETS, normalized) + this.markUserQuestionSetsSynced(syncedAt) + } + + public upsertUserQuestionSets(sets: UserQuestionSetLike[], syncedAt?: string): void { + if (sets.length === 0) { + this.markUserQuestionSetsSynced(syncedAt) + return + } + + const existing = this.getCachedUserQuestionSets() + const byId = new Map(existing.map(set => [set.id, set])) + + sets.forEach(set => { + byId.set(set.id, this.normalizeUserQuestionSet(set)) + }) + + const merged = Array.from(byId.values()).sort((a, b) => { + const updatedA = new Date(a.updatedAt ?? '').getTime() || 0 + const updatedB = new Date(b.updatedAt ?? '').getTime() || 0 + return updatedB - updatedA + }) + + this.setItem(STORAGE_KEYS.USER_QUESTION_SETS, merged) + this.markUserQuestionSetsSynced(syncedAt) + } + + public getQuestionsFromUserSetIds(setIds: string[]): InterviewQuestion[] { + if (!Array.isArray(setIds) || setIds.length === 0) { + return [] + } + + const uniqueIds = setIds.filter((id, index) => setIds.indexOf(id) === index) + const sets = this.getCachedUserQuestionSets() + const setMap = new Map(sets.map(set => [set.id, set])) + + const aggregated: InterviewQuestion[] = [] + + uniqueIds.forEach(id => { + const set = setMap.get(id) + if (!set) { + return + } + + set.questions.forEach(question => { + aggregated.push(this.normalizeInterviewQuestion(question, set.id, set.tags)) + }) + }) + + const seen = new Set() + const deduped: InterviewQuestion[] = [] + + aggregated.forEach(question => { + if (seen.has(question.id)) { + return + } + + seen.add(question.id) + deduped.push(question) + }) + + return deduped + } + + public removeUserQuestionSet(id: string): void { + const sets = this.getCachedUserQuestionSets().filter(set => set.id !== id) + this.setItem(STORAGE_KEYS.USER_QUESTION_SETS, sets) + } + + public clearUserQuestionSets(): void { + this.setItem(STORAGE_KEYS.USER_QUESTION_SETS, []) + this.setItem(STORAGE_KEYS.USER_QUESTION_SET_SYNC_STATE, this.normalizeSyncState(null)) + } + + public markUserQuestionSetsSynced(syncedAt?: string | null): void { + if (syncedAt === null) { + this.setItem( + STORAGE_KEYS.USER_QUESTION_SET_SYNC_STATE, + this.normalizeSyncState({ lastSyncedAt: null }) + ) + return + } + + const timestamp = syncedAt ?? new Date().toISOString() + + this.setItem( + STORAGE_KEYS.USER_QUESTION_SET_SYNC_STATE, + this.normalizeSyncState({ lastSyncedAt: timestamp }) + ) + } + + public getUserQuestionSetSyncState(): UserQuestionSetSyncState { + const state = this.getItem(STORAGE_KEYS.USER_QUESTION_SET_SYNC_STATE) + return this.normalizeSyncState(state) + } + // User Preferences public savePreferences(preferences: Partial): void { const existing = this.getPreferences() @@ -177,6 +367,8 @@ export class OfflineStorage { preferences: this.getPreferences(), templates: this.getCachedTemplates(), questions: this.getCachedQuestions(), + userQuestionSets: this.getCachedUserQuestionSets(), + userQuestionSetSyncState: this.getUserQuestionSetSyncState(), exportedAt: new Date().toISOString(), } @@ -202,6 +394,15 @@ export class OfflineStorage { if (data.questions) { this.setItem(STORAGE_KEYS.CACHED_QUESTIONS, data.questions) } + + if (Array.isArray(data.userQuestionSets)) { + this.syncUserQuestionSets(data.userQuestionSets) + } + + if (data.userQuestionSetSyncState) { + const normalizedState = this.normalizeSyncState(data.userQuestionSetSyncState) + this.setItem(STORAGE_KEYS.USER_QUESTION_SET_SYNC_STATE, normalizedState) + } return true } catch (error) { @@ -216,11 +417,22 @@ export class OfflineStorage { if (!this.getItem(STORAGE_KEYS.TEMPLATES)) { this.cacheTemplates() } - + // Cache default questions if not already cached if (!this.getItem(STORAGE_KEYS.CACHED_QUESTIONS)) { this.addQuestionsToCache(mockQuestions) } + + if (!this.getItem(STORAGE_KEYS.USER_QUESTION_SETS)) { + this.setItem(STORAGE_KEYS.USER_QUESTION_SETS, []) + } + + if (!this.getItem(STORAGE_KEYS.USER_QUESTION_SET_SYNC_STATE)) { + this.setItem( + STORAGE_KEYS.USER_QUESTION_SET_SYNC_STATE, + this.normalizeSyncState(null) + ) + } } } diff --git a/lib/session-manager.ts b/lib/session-manager.ts index ab87963..ace0e91 100644 --- a/lib/session-manager.ts +++ b/lib/session-manager.ts @@ -2,10 +2,12 @@ import { nanoid } from 'nanoid' import { offlineStorage, type StoredSession } from './offline-storage' import { interviewTemplates } from '@/data/interview-templates' import { openAIService } from './openai' -import type { InterviewSession, InterviewQuestion } from '@/types/interview' +import type { InterviewSession, InterviewQuestion, QuestionSourceMetadata } from '@/types/interview' export interface SessionCreateParams { templateId?: string + userQuestionSetIds?: string[] + includeDefaultQuestions?: boolean role?: string type: 'behavioral' | 'technical' | 'mixed' difficulty: 'easy' | 'medium' | 'hard' @@ -31,24 +33,63 @@ export class SessionManager { let questions: InterviewQuestion[] = [] try { - if (params.templateId) { - // Use template questions - const template = interviewTemplates.find(t => t.id === params.templateId) - if (template) { - questions = template.questions + const questionSources: QuestionSourceMetadata = {} + const selectedUserSetIds = params.userQuestionSetIds?.filter(id => !!id) ?? [] + const includeDefault = params.includeDefaultQuestions ?? false + + let userQuestions: InterviewQuestion[] = [] + if (selectedUserSetIds.length > 0) { + userQuestions = offlineStorage.getQuestionsFromUserSetIds(selectedUserSetIds) + if (userQuestions.length > 0) { + questionSources.userSetIds = Array.from(new Set(selectedUserSetIds)) + } + } + + const selectedTemplate = params.templateId + ? interviewTemplates.find(t => t.id === params.templateId) + : undefined + const templateQuestions = selectedTemplate + ? selectedTemplate.questions.map(question => ({ + ...question, + origin: question.origin ?? 'template', + })) + : [] + let templateUsed = false + + if (userQuestions.length > 0) { + questions = [...userQuestions] + + if (includeDefault) { + const fallbackPool = templateQuestions.length > 0 + ? templateQuestions + : this.getCachedQuestionsByType(params.type, params.difficulty) + + if (fallbackPool.length > 0) { + questions = this.mergeQuestionCollections(questions, fallbackPool) + questionSources.includedDefault = true + + if (templateQuestions.length > 0) { + templateUsed = true + } + } } - } else if (params.customQuestions) { - // Use custom questions + } else if (templateQuestions.length > 0) { + questions = [...templateQuestions] + templateUsed = true + } + + if (questions.length === 0 && params.customQuestions) { questions = params.customQuestions - } else { - // Generate AI questions if online, fallback to cached questions if offline - if (navigator.onLine && params.role) { + } + + if (questions.length === 0) { + if (typeof navigator !== 'undefined' && navigator.onLine && params.role) { try { const generatedQuestions = await openAIService.generateQuestions({ role: params.role, type: params.type, difficulty: params.difficulty, - count: Math.floor(params.duration / 10), // Rough estimate of questions per duration + count: Math.max(1, Math.floor(params.duration / 10)), }) questions = generatedQuestions.map((q, index) => ({ @@ -57,9 +98,12 @@ export class SessionManager { difficulty: q.difficulty as 'easy' | 'medium' | 'hard', question: q.question, followUp: q.followUp, - timeLimit: q.timeLimit + timeLimit: q.timeLimit, + origin: 'ai', })) + questionSources.aiGenerated = true + // Cache the generated questions for offline use offlineStorage.addQuestionsToCache(questions) } catch (error) { @@ -67,11 +111,19 @@ export class SessionManager { questions = this.getCachedQuestionsByType(params.type, params.difficulty) } } else { - // Use cached questions when offline + // Use cached questions when offline or role information is missing questions = this.getCachedQuestionsByType(params.type, params.difficulty) } } + if (questions.length === 0) { + questions = this.getCachedQuestionsByType(params.type, params.difficulty) + } + + if (templateUsed && selectedTemplate) { + questionSources.templateId = selectedTemplate.id + } + const session: InterviewSession = { id: sessionId, type: params.type, @@ -79,7 +131,8 @@ export class SessionManager { difficulty: params.difficulty, questions, currentQuestionIndex: 0, - status: 'setup' + status: 'setup', + ...(Object.keys(questionSources).length > 0 ? { questionSources } : {}), } // Save to offline storage @@ -88,11 +141,11 @@ export class SessionManager { session, responses: [], createdAt: new Date(), - updatedAt: new Date() + updatedAt: new Date(), } offlineStorage.saveSession(storedSession) - + return session } catch (error) { console.error('Failed to create session:', error) @@ -100,6 +153,30 @@ export class SessionManager { } } + private mergeQuestionCollections( + primary: InterviewQuestion[], + secondary: InterviewQuestion[] + ): InterviewQuestion[] { + const seen = new Set() + const merged: InterviewQuestion[] = [] + + primary.forEach(question => { + if (!seen.has(question.id)) { + merged.push(question) + seen.add(question.id) + } + }) + + secondary.forEach(question => { + if (!seen.has(question.id)) { + merged.push(question) + seen.add(question.id) + } + }) + + return merged + } + // Get cached questions by type and difficulty private getCachedQuestionsByType( type: 'behavioral' | 'technical' | 'mixed', diff --git a/supabase/migrations/003_user_question_sets.sql b/supabase/migrations/003_user_question_sets.sql new file mode 100644 index 0000000..9490759 --- /dev/null +++ b/supabase/migrations/003_user_question_sets.sql @@ -0,0 +1,106 @@ +-- Create user question sets table +CREATE TABLE IF NOT EXISTS user_question_sets ( + id UUID DEFAULT gen_random_uuid() PRIMARY KEY, + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + title TEXT NOT NULL, + industry TEXT, + tags TEXT[] DEFAULT '{}'::TEXT[], + difficulty TEXT DEFAULT 'medium' CHECK (difficulty IN ('easy', 'medium', 'hard', 'mixed')), + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Indexes for user question sets +CREATE INDEX IF NOT EXISTS idx_user_question_sets_user_id ON user_question_sets(user_id); +CREATE INDEX IF NOT EXISTS idx_user_question_sets_updated_at ON user_question_sets(updated_at); +CREATE INDEX IF NOT EXISTS idx_user_question_sets_difficulty ON user_question_sets(difficulty); + +-- Enable Row Level Security +ALTER TABLE user_question_sets ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "Users can view their question sets" ON user_question_sets + FOR SELECT USING (auth.uid() = user_id); + +CREATE POLICY "Users can insert their question sets" ON user_question_sets + FOR INSERT WITH CHECK (auth.uid() = user_id); + +CREATE POLICY "Users can update their question sets" ON user_question_sets + FOR UPDATE USING (auth.uid() = user_id); + +CREATE POLICY "Users can delete their question sets" ON user_question_sets + FOR DELETE USING (auth.uid() = user_id); + +-- Trigger to maintain updated_at column +CREATE TRIGGER update_user_question_sets_updated_at + BEFORE UPDATE ON user_question_sets + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); + +-- Create user questions table +CREATE TABLE IF NOT EXISTS user_questions ( + id UUID DEFAULT gen_random_uuid() PRIMARY KEY, + set_id UUID NOT NULL REFERENCES user_question_sets(id) ON DELETE CASCADE, + prompt TEXT NOT NULL, + follow_ups TEXT[] DEFAULT '{}'::TEXT[], + difficulty TEXT DEFAULT 'medium' CHECK (difficulty IN ('easy', 'medium', 'hard')), + question_type TEXT NOT NULL CHECK (question_type IN ('behavioral', 'technical', 'situational')), + time_limit INTEGER, + tags TEXT[] DEFAULT '{}'::TEXT[], + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW() +); + +-- Indexes for user questions +CREATE INDEX IF NOT EXISTS idx_user_questions_set_id ON user_questions(set_id); +CREATE INDEX IF NOT EXISTS idx_user_questions_difficulty ON user_questions(difficulty); +CREATE INDEX IF NOT EXISTS idx_user_questions_question_type ON user_questions(question_type); + +-- Enable Row Level Security +ALTER TABLE user_questions ENABLE ROW LEVEL SECURITY; + +CREATE POLICY "Users can view their questions" ON user_questions + FOR SELECT USING ( + EXISTS ( + SELECT 1 FROM user_question_sets + WHERE user_question_sets.id = user_questions.set_id + AND user_question_sets.user_id = auth.uid() + ) + ); + +CREATE POLICY "Users can insert questions into their sets" ON user_questions + FOR INSERT WITH CHECK ( + EXISTS ( + SELECT 1 FROM user_question_sets + WHERE user_question_sets.id = user_questions.set_id + AND user_question_sets.user_id = auth.uid() + ) + ); + +CREATE POLICY "Users can update their questions" ON user_questions + FOR UPDATE USING ( + EXISTS ( + SELECT 1 FROM user_question_sets + WHERE user_question_sets.id = user_questions.set_id + AND user_question_sets.user_id = auth.uid() + ) + ) + WITH CHECK ( + EXISTS ( + SELECT 1 FROM user_question_sets + WHERE user_question_sets.id = user_questions.set_id + AND user_question_sets.user_id = auth.uid() + ) + ); + +CREATE POLICY "Users can delete their questions" ON user_questions + FOR DELETE USING ( + EXISTS ( + SELECT 1 FROM user_question_sets + WHERE user_question_sets.id = user_questions.set_id + AND user_question_sets.user_id = auth.uid() + ) + ); + +-- Trigger to maintain updated_at column +CREATE TRIGGER update_user_questions_updated_at + BEFORE UPDATE ON user_questions + FOR EACH ROW EXECUTE FUNCTION update_updated_at_column(); diff --git a/types/interview.ts b/types/interview.ts index 7842c84..2318f85 100644 --- a/types/interview.ts +++ b/types/interview.ts @@ -1,10 +1,22 @@ +export type QuestionOrigin = "default" | "template" | "ai" | "user" + export interface InterviewQuestion { id: string type: "behavioral" | "technical" | "situational" difficulty: "easy" | "medium" | "hard" question: string followUp?: string[] - timeLimit?: number // in seconds + timeLimit?: number + origin?: QuestionOrigin + setId?: string + tags?: string[] +} + +export interface QuestionSourceMetadata { + templateId?: string + userSetIds?: string[] + includedDefault?: boolean + aiGenerated?: boolean } export interface InterviewSession { @@ -17,6 +29,7 @@ export interface InterviewSession { startTime?: Date endTime?: Date status: "setup" | "active" | "paused" | "completed" + questionSources?: QuestionSourceMetadata } export interface VoiceState { @@ -33,3 +46,18 @@ export interface SessionControls { currentQuestion: number totalQuestions: number } + +export interface UserQuestionSet { + id: string + title: string + industry?: string | null + tags: string[] + difficulty: "easy" | "medium" | "hard" | "mixed" + createdAt: string + updatedAt: string + questions: InterviewQuestion[] +} + +export interface QuestionBankSyncState { + lastSyncedAt: string | null +}