453 lines
11 KiB
TypeScript
453 lines
11 KiB
TypeScript
import { v4 as uuidv4 } from 'uuid';
|
|
import {
|
|
User,
|
|
Medication,
|
|
UserSettings,
|
|
TakenDoses,
|
|
CustomReminder,
|
|
CouchDBDocument,
|
|
UserRole,
|
|
} from '../../types';
|
|
import { AccountStatus } from '../auth/auth.constants';
|
|
import { DatabaseStrategy, DatabaseError } from './types';
|
|
import { getDatabaseConfig } from '../../config/unified.config';
|
|
import { logger } from '../logging';
|
|
|
|
export class ProductionDatabaseStrategy implements DatabaseStrategy {
|
|
private baseUrl: string;
|
|
private auth: string;
|
|
|
|
constructor() {
|
|
// Get CouchDB configuration from unified config
|
|
const dbConfig = getDatabaseConfig();
|
|
|
|
this.baseUrl = dbConfig.url;
|
|
this.auth = btoa(`${dbConfig.username}:${dbConfig.password}`);
|
|
|
|
logger.db.query('Initializing production database strategy', {
|
|
url: dbConfig.url,
|
|
username: dbConfig.username,
|
|
});
|
|
|
|
// Provision required databases on startup (non-blocking)
|
|
this.initializeDatabases().catch(error => {
|
|
logger.db.error('Failed to initialize databases', error as Error);
|
|
});
|
|
}
|
|
|
|
private async initializeDatabases(): Promise<void> {
|
|
const databases = [
|
|
'users',
|
|
'medications',
|
|
'settings',
|
|
'taken_doses',
|
|
'reminders',
|
|
];
|
|
|
|
for (const dbName of databases) {
|
|
try {
|
|
await this.createDatabaseIfNotExists(dbName);
|
|
if (dbName === 'users') {
|
|
await this.ensureUserEmailIndex();
|
|
}
|
|
} catch (error) {
|
|
logger.db.error(
|
|
`Failed to initialize database ${dbName}`,
|
|
error as Error
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
private async ensureUserEmailIndex(): Promise<void> {
|
|
try {
|
|
const indexes = await this.makeRequest<{
|
|
indexes: Array<{
|
|
name: string;
|
|
def: { fields: Array<Record<string, string>> };
|
|
}>;
|
|
}>('GET', '/users/_index');
|
|
|
|
const hasEmailIndex = indexes.indexes.some(index => {
|
|
if (index.name === 'email-index') {
|
|
return true;
|
|
}
|
|
return index.def.fields.some(
|
|
field => Object.keys(field)[0] === 'email'
|
|
);
|
|
});
|
|
|
|
if (hasEmailIndex) {
|
|
return;
|
|
}
|
|
|
|
await this.makeRequest('POST', '/users/_index', {
|
|
index: { fields: ['email'] },
|
|
name: 'email-index',
|
|
type: 'json',
|
|
});
|
|
|
|
logger.db.query('Created email index for users database');
|
|
} catch (error) {
|
|
logger.db.error('Failed to ensure user email index', error as Error);
|
|
}
|
|
}
|
|
|
|
private async createDatabaseIfNotExists(dbName: string): Promise<void> {
|
|
try {
|
|
// Check if database exists
|
|
const response = await fetch(`${this.baseUrl}/${dbName}`, {
|
|
method: 'HEAD',
|
|
headers: {
|
|
Authorization: `Basic ${this.auth}`,
|
|
},
|
|
});
|
|
|
|
if (response.status === 404) {
|
|
// Database doesn't exist, create it
|
|
const createResponse = await fetch(`${this.baseUrl}/${dbName}`, {
|
|
method: 'PUT',
|
|
headers: {
|
|
Authorization: `Basic ${this.auth}`,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
});
|
|
|
|
if (!createResponse.ok) {
|
|
throw new DatabaseError(
|
|
`Failed to create database ${dbName}`,
|
|
createResponse.status
|
|
);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
if (error instanceof DatabaseError) {
|
|
throw error;
|
|
}
|
|
throw new DatabaseError(
|
|
`Database initialization failed for ${dbName}`,
|
|
500
|
|
);
|
|
}
|
|
}
|
|
|
|
private async makeRequest<T>(
|
|
method: string,
|
|
path: string,
|
|
body?: unknown
|
|
): Promise<T> {
|
|
const url = `${this.baseUrl}${path}`;
|
|
const headers: Record<string, string> = {
|
|
Authorization: `Basic ${this.auth}`,
|
|
'Content-Type': 'application/json',
|
|
};
|
|
|
|
const config: RequestInit = {
|
|
method,
|
|
headers,
|
|
};
|
|
|
|
if (body) {
|
|
config.body = JSON.stringify(body);
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(url, config);
|
|
|
|
if (!response.ok) {
|
|
const errorText = await response.text();
|
|
throw new DatabaseError(
|
|
`HTTP ${response.status}: ${errorText}`,
|
|
response.status
|
|
);
|
|
}
|
|
|
|
return await response.json();
|
|
} catch (error) {
|
|
if (error instanceof DatabaseError) {
|
|
throw error;
|
|
}
|
|
throw new DatabaseError(`Network error: ${error}`, 500);
|
|
}
|
|
}
|
|
|
|
private async getDoc<T extends CouchDBDocument>(
|
|
dbName: string,
|
|
id: string
|
|
): Promise<T | null> {
|
|
try {
|
|
return await this.makeRequest<T>('GET', `/${dbName}/${id}`);
|
|
} catch (error) {
|
|
if (error instanceof DatabaseError && error.status === 404) {
|
|
return null;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
private async putDoc<T extends CouchDBDocument>(
|
|
dbName: string,
|
|
doc: T
|
|
): Promise<T> {
|
|
const response = await this.makeRequest<{ id: string; rev: string }>(
|
|
'PUT',
|
|
`/${dbName}/${doc._id}`,
|
|
doc
|
|
);
|
|
|
|
return {
|
|
...doc,
|
|
_rev: response.rev,
|
|
};
|
|
}
|
|
|
|
private async deleteDoc(
|
|
dbName: string,
|
|
id: string,
|
|
rev: string
|
|
): Promise<boolean> {
|
|
try {
|
|
await this.makeRequest('DELETE', `/${dbName}/${id}?rev=${rev}`);
|
|
return true;
|
|
} catch (error) {
|
|
if (error instanceof DatabaseError && error.status === 404) {
|
|
return false;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
private async queryByKey<T>(
|
|
dbName: string,
|
|
startKey: string,
|
|
endKey?: string
|
|
): Promise<T[]> {
|
|
const params = new URLSearchParams({
|
|
startkey: JSON.stringify(startKey),
|
|
include_docs: 'true',
|
|
});
|
|
|
|
if (endKey) {
|
|
params.append('endkey', JSON.stringify(endKey));
|
|
}
|
|
|
|
const response = await this.makeRequest<{
|
|
rows: Array<{ doc: T }>;
|
|
}>('GET', `/${dbName}/_all_docs?${params}`);
|
|
|
|
return response.rows.map(row => row.doc);
|
|
}
|
|
|
|
// User operations
|
|
async createUser(user: Omit<User, '_id' | '_rev'>): Promise<User> {
|
|
const newUser: User = {
|
|
...user,
|
|
_id: uuidv4(),
|
|
_rev: '', // Will be set by CouchDB
|
|
status: user.status || AccountStatus.ACTIVE,
|
|
role: user.role || UserRole.USER,
|
|
createdAt: user.createdAt || new Date(),
|
|
};
|
|
|
|
return this.putDoc('users', newUser);
|
|
}
|
|
|
|
async updateUser(user: User): Promise<User> {
|
|
return this.putDoc('users', user);
|
|
}
|
|
|
|
async getUserById(id: string): Promise<User | null> {
|
|
return this.getDoc<User>('users', id);
|
|
}
|
|
|
|
async findUserByEmail(email: string): Promise<User | null> {
|
|
const response = await this.makeRequest<{
|
|
docs: User[];
|
|
warning?: string;
|
|
}>('POST', '/users/_find', {
|
|
selector: { email },
|
|
limit: 1,
|
|
});
|
|
|
|
return response.docs[0] || null;
|
|
}
|
|
|
|
async deleteUser(id: string): Promise<boolean> {
|
|
const user = await this.getDoc<User>('users', id);
|
|
if (!user) {
|
|
return false;
|
|
}
|
|
return this.deleteDoc('users', id, user._rev);
|
|
}
|
|
|
|
async getAllUsers(): Promise<User[]> {
|
|
const response = await this.makeRequest<{
|
|
rows: Array<{ doc: User }>;
|
|
}>('GET', '/users/_all_docs?include_docs=true');
|
|
|
|
return response.rows.map(row => row.doc);
|
|
}
|
|
|
|
// Medication operations
|
|
async createMedication(
|
|
userId: string,
|
|
medication: Omit<Medication, '_id' | '_rev'>
|
|
): Promise<Medication> {
|
|
const newMedication: Medication = {
|
|
...medication,
|
|
_id: `${userId}-med-${uuidv4()}`,
|
|
_rev: '',
|
|
};
|
|
|
|
return this.putDoc('medications', newMedication);
|
|
}
|
|
|
|
async updateMedication(medication: Medication): Promise<Medication> {
|
|
return this.putDoc('medications', medication);
|
|
}
|
|
|
|
async getMedications(userId: string): Promise<Medication[]> {
|
|
return this.queryByKey<Medication>(
|
|
'medications',
|
|
`${userId}-med-`,
|
|
`${userId}-med-\ufff0`
|
|
);
|
|
}
|
|
|
|
async deleteMedication(id: string): Promise<boolean> {
|
|
const medication = await this.getDoc<Medication>('medications', id);
|
|
if (!medication) {
|
|
return false;
|
|
}
|
|
return this.deleteDoc('medications', id, medication._rev);
|
|
}
|
|
|
|
// User settings operations
|
|
async getUserSettings(userId: string): Promise<UserSettings> {
|
|
const existing = await this.getDoc<UserSettings>('settings', userId);
|
|
if (existing) {
|
|
return existing;
|
|
}
|
|
|
|
// Create default settings if none exist
|
|
const defaultSettings: UserSettings = {
|
|
_id: userId,
|
|
_rev: '',
|
|
notificationsEnabled: true,
|
|
hasCompletedOnboarding: false,
|
|
};
|
|
|
|
return this.putDoc('settings', defaultSettings);
|
|
}
|
|
|
|
async updateUserSettings(settings: UserSettings): Promise<UserSettings> {
|
|
return this.putDoc('settings', settings);
|
|
}
|
|
|
|
// Taken doses operations
|
|
async getTakenDoses(userId: string): Promise<TakenDoses> {
|
|
const existing = await this.getDoc<TakenDoses>('taken_doses', userId);
|
|
if (existing) {
|
|
return existing;
|
|
}
|
|
|
|
// Create default taken doses record if none exists
|
|
const defaultTakenDoses: TakenDoses = {
|
|
_id: userId,
|
|
_rev: '',
|
|
doses: {},
|
|
};
|
|
|
|
return this.putDoc('taken_doses', defaultTakenDoses);
|
|
}
|
|
|
|
async updateTakenDoses(takenDoses: TakenDoses): Promise<TakenDoses> {
|
|
return this.putDoc('taken_doses', takenDoses);
|
|
}
|
|
|
|
// Custom reminders operations
|
|
async createCustomReminder(
|
|
userId: string,
|
|
reminder: Omit<CustomReminder, '_id' | '_rev'>
|
|
): Promise<CustomReminder> {
|
|
const newReminder: CustomReminder = {
|
|
...reminder,
|
|
_id: `${userId}-reminder-${uuidv4()}`,
|
|
_rev: '',
|
|
};
|
|
|
|
return this.putDoc('reminders', newReminder);
|
|
}
|
|
|
|
async updateCustomReminder(
|
|
reminder: CustomReminder
|
|
): Promise<CustomReminder> {
|
|
return this.putDoc('reminders', reminder);
|
|
}
|
|
|
|
async getCustomReminders(userId: string): Promise<CustomReminder[]> {
|
|
return this.queryByKey<CustomReminder>(
|
|
'reminders',
|
|
`${userId}-reminder-`,
|
|
`${userId}-reminder-\ufff0`
|
|
);
|
|
}
|
|
|
|
async deleteCustomReminder(id: string): Promise<boolean> {
|
|
const reminder = await this.getDoc<CustomReminder>('reminders', id);
|
|
if (!reminder) {
|
|
return false;
|
|
}
|
|
return this.deleteDoc('reminders', id, reminder._rev);
|
|
}
|
|
|
|
// User operations with password
|
|
async createUserWithPassword(
|
|
email: string,
|
|
hashedPassword: string,
|
|
username?: string
|
|
): Promise<User> {
|
|
// Check if user already exists
|
|
const existingUser = await this.findUserByEmail(email);
|
|
if (existingUser) {
|
|
throw new DatabaseError('User already exists with this email', 409);
|
|
}
|
|
|
|
return this.createUser({
|
|
username: username || email.split('@')[0],
|
|
email,
|
|
password: hashedPassword,
|
|
emailVerified: false,
|
|
status: AccountStatus.PENDING,
|
|
role: UserRole.USER,
|
|
createdAt: new Date(),
|
|
});
|
|
}
|
|
|
|
async createUserFromOAuth(
|
|
email: string,
|
|
username: string,
|
|
_provider: string
|
|
): Promise<User> {
|
|
// Check if user already exists
|
|
const existingUser = await this.findUserByEmail(email);
|
|
if (existingUser) {
|
|
// Update last login and return existing user
|
|
return this.updateUser({
|
|
...existingUser,
|
|
lastLoginAt: new Date(),
|
|
});
|
|
}
|
|
|
|
return this.createUser({
|
|
username,
|
|
email,
|
|
emailVerified: true, // OAuth emails are considered verified
|
|
status: AccountStatus.ACTIVE,
|
|
role: UserRole.USER,
|
|
createdAt: new Date(),
|
|
lastLoginAt: new Date(),
|
|
});
|
|
}
|
|
}
|