- Migrated from Python pre-commit to NodeJS-native solution - Reorganized documentation structure - Set up Husky + lint-staged for efficient pre-commit hooks - Fixed Dockerfile healthcheck issue - Added comprehensive documentation index
403 lines
12 KiB
TypeScript
403 lines
12 KiB
TypeScript
import { v4 as uuidv4 } from 'uuid';
|
|
import {
|
|
User,
|
|
Medication,
|
|
UserSettings,
|
|
TakenDoses,
|
|
CustomReminder,
|
|
CouchDBDocument,
|
|
UserRole,
|
|
} from '../types';
|
|
import { AccountStatus } from './auth/auth.constants';
|
|
|
|
// This is a mock CouchDB service that uses localStorage for persistence.
|
|
// It mimics the async nature of a real database API and includes robust error handling and conflict resolution.
|
|
|
|
const latency = () =>
|
|
new Promise(res => setTimeout(res, Math.random() * 200 + 50));
|
|
|
|
export class CouchDBError extends Error {
|
|
status: number;
|
|
constructor(message: string, status: number) {
|
|
super(message);
|
|
this.name = 'CouchDBError';
|
|
this.status = status;
|
|
}
|
|
}
|
|
|
|
class CouchDBService {
|
|
private async getDb<T>(dbName: string): Promise<T[]> {
|
|
await latency();
|
|
const db = localStorage.getItem(dbName);
|
|
return db ? JSON.parse(db) : [];
|
|
}
|
|
|
|
private async saveDb<T>(dbName: string, data: T[]): Promise<void> {
|
|
await latency();
|
|
localStorage.setItem(dbName, JSON.stringify(data));
|
|
}
|
|
|
|
private async getDoc<T extends CouchDBDocument>(
|
|
dbName: string,
|
|
id: string
|
|
): Promise<T | null> {
|
|
const allDocs = await this.getDb<T>(dbName);
|
|
return allDocs.find(doc => doc._id === id) || null;
|
|
}
|
|
|
|
private async query<T>(
|
|
dbName: string,
|
|
predicate: (doc: T) => boolean
|
|
): Promise<T[]> {
|
|
const allDocs = await this.getDb<T>(dbName);
|
|
return allDocs.filter(predicate);
|
|
}
|
|
|
|
private async putDoc<T extends CouchDBDocument>(
|
|
dbName: string,
|
|
doc: Omit<T, '_rev'> & { _rev?: string }
|
|
): Promise<T> {
|
|
const allDocs = await this.getDb<T>(dbName);
|
|
const docIndex = allDocs.findIndex(d => d._id === doc._id);
|
|
|
|
if (docIndex > -1) {
|
|
// Update
|
|
const existingDoc = allDocs[docIndex];
|
|
if (existingDoc._rev !== doc._rev) {
|
|
throw new CouchDBError('Document update conflict', 409);
|
|
}
|
|
const newRev = parseInt(existingDoc._rev.split('-')[0], 10) + 1;
|
|
const updatedDoc = {
|
|
...doc,
|
|
_rev: `${newRev}-${Math.random().toString(36).substr(2, 9)}`,
|
|
} as T;
|
|
allDocs[docIndex] = updatedDoc;
|
|
await this.saveDb(dbName, allDocs);
|
|
return updatedDoc;
|
|
} else {
|
|
// Create
|
|
const newDoc = {
|
|
...doc,
|
|
_rev: `1-${Math.random().toString(36).substr(2, 9)}`,
|
|
} as T;
|
|
allDocs.push(newDoc);
|
|
await this.saveDb(dbName, allDocs);
|
|
return newDoc;
|
|
}
|
|
}
|
|
|
|
private async deleteDoc<T extends CouchDBDocument>(
|
|
dbName: string,
|
|
doc: T
|
|
): Promise<void> {
|
|
let docs = await this.getDb<T>(dbName);
|
|
const docIndex = docs.findIndex(d => d._id === doc._id);
|
|
if (docIndex > -1) {
|
|
if (docs[docIndex]._rev !== doc._rev) {
|
|
throw new CouchDBError('Document update conflict', 409);
|
|
}
|
|
docs = docs.filter(m => m._id !== doc._id);
|
|
await this.saveDb(dbName, docs);
|
|
} else {
|
|
throw new CouchDBError('Document not found', 404);
|
|
}
|
|
}
|
|
|
|
// Generic update function with conflict resolution
|
|
private async updateDocWithConflictResolution<T extends CouchDBDocument>(
|
|
dbName: string,
|
|
doc: T,
|
|
mergeFn?: (latest: T, incoming: T) => T
|
|
): Promise<T> {
|
|
try {
|
|
return await this.putDoc<T>(dbName, doc);
|
|
} catch (error) {
|
|
if (error instanceof CouchDBError && error.status === 409) {
|
|
console.warn(
|
|
`Conflict detected for doc ${doc._id}. Attempting to resolve.`
|
|
);
|
|
const latestDoc = await this.getDoc<T>(dbName, doc._id);
|
|
if (latestDoc) {
|
|
// Default merge: incoming changes overwrite latest
|
|
const defaultMerge = { ...latestDoc, ...doc, _rev: latestDoc._rev };
|
|
const mergedDoc = mergeFn ? mergeFn(latestDoc, doc) : defaultMerge;
|
|
// Retry the update with the latest revision and merged data
|
|
return this.putDoc<T>(dbName, mergedDoc);
|
|
}
|
|
}
|
|
// Re-throw if it's not a resolvable conflict or fetching the latest doc fails
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// --- User Management ---
|
|
async findUserByUsername(username: string): Promise<User | null> {
|
|
const users = await this.query<User>(
|
|
'users',
|
|
u => u.username.toLowerCase() === username.toLowerCase()
|
|
);
|
|
return users[0] || null;
|
|
}
|
|
|
|
async findUserByEmail(email: string): Promise<User | null> {
|
|
const users = await this.query<User>(
|
|
'users',
|
|
u => u.email?.toLowerCase() === email.toLowerCase()
|
|
);
|
|
return users[0] || null;
|
|
}
|
|
|
|
async createUser(username: string): Promise<User> {
|
|
if (await this.findUserByUsername(username)) {
|
|
throw new CouchDBError('User already exists', 409);
|
|
}
|
|
const newUser: Omit<User, '_rev'> = { _id: uuidv4(), username };
|
|
return this.putDoc<User>('users', newUser);
|
|
}
|
|
|
|
async createUserWithPassword(
|
|
email: string,
|
|
password: string,
|
|
username?: string
|
|
): Promise<User> {
|
|
// Check if user already exists by email
|
|
const existingUser = await this.findUserByEmail(email);
|
|
if (existingUser) {
|
|
throw new CouchDBError('User already exists', 409);
|
|
}
|
|
|
|
const newUser: Omit<User, '_rev'> = {
|
|
_id: uuidv4(),
|
|
username: username || email.split('@')[0], // Default username from email
|
|
email,
|
|
password, // In production, this should be hashed with bcrypt
|
|
emailVerified: false, // Require email verification for password accounts
|
|
status: AccountStatus.PENDING,
|
|
role: UserRole.USER, // Default role is USER
|
|
createdAt: new Date(),
|
|
lastLoginAt: new Date(),
|
|
};
|
|
return this.putDoc<User>('users', newUser);
|
|
}
|
|
|
|
async createUserFromOAuth(userData: {
|
|
email: string;
|
|
username: string;
|
|
avatar?: string;
|
|
}): Promise<User> {
|
|
// Check if user already exists by email
|
|
const existingUser = await this.findUserByEmail(userData.email);
|
|
if (existingUser) {
|
|
throw new CouchDBError('User already exists', 409);
|
|
}
|
|
|
|
const newUser: Omit<User, '_rev'> = {
|
|
_id: uuidv4(),
|
|
username: userData.username,
|
|
email: userData.email,
|
|
avatar: userData.avatar,
|
|
emailVerified: true, // OAuth users have verified emails
|
|
status: AccountStatus.ACTIVE,
|
|
role: UserRole.USER, // Default role is USER
|
|
createdAt: new Date(),
|
|
lastLoginAt: new Date(),
|
|
};
|
|
return this.putDoc<User>('users', newUser);
|
|
}
|
|
|
|
async updateUser(user: User): Promise<User> {
|
|
return this.updateDocWithConflictResolution<User>('users', user);
|
|
}
|
|
|
|
// --- Admin User Management ---
|
|
async getAllUsers(): Promise<User[]> {
|
|
return this.getDb<User>('users');
|
|
}
|
|
|
|
async getUserById(userId: string): Promise<User | null> {
|
|
return this.getDoc<User>('users', userId);
|
|
}
|
|
|
|
async suspendUser(userId: string): Promise<User> {
|
|
const user = await this.getUserById(userId);
|
|
if (!user) {
|
|
throw new CouchDBError('User not found', 404);
|
|
}
|
|
|
|
const updatedUser = { ...user, status: AccountStatus.SUSPENDED };
|
|
return this.updateUser(updatedUser);
|
|
}
|
|
|
|
async activateUser(userId: string): Promise<User> {
|
|
const user = await this.getUserById(userId);
|
|
if (!user) {
|
|
throw new CouchDBError('User not found', 404);
|
|
}
|
|
|
|
const updatedUser = { ...user, status: AccountStatus.ACTIVE };
|
|
return this.updateUser(updatedUser);
|
|
}
|
|
|
|
async deleteUser(userId: string): Promise<void> {
|
|
const user = await this.getUserById(userId);
|
|
if (!user) {
|
|
throw new CouchDBError('User not found', 404);
|
|
}
|
|
|
|
// Delete user data
|
|
await this.deleteDoc<User>('users', user);
|
|
|
|
// Delete user's associated data
|
|
const userMeds = this.getUserDbName('meds', userId);
|
|
const userSettings = this.getUserDbName('settings', userId);
|
|
const userTaken = this.getUserDbName('taken', userId);
|
|
const userReminders = this.getUserDbName('reminders', userId);
|
|
|
|
localStorage.removeItem(userMeds);
|
|
localStorage.removeItem(userSettings);
|
|
localStorage.removeItem(userTaken);
|
|
localStorage.removeItem(userReminders);
|
|
}
|
|
|
|
async changeUserPassword(userId: string, newPassword: string): Promise<User> {
|
|
const user = await this.getUserById(userId);
|
|
if (!user) {
|
|
throw new CouchDBError('User not found', 404);
|
|
}
|
|
|
|
// In production, hash the password with bcrypt
|
|
const updatedUser = { ...user, password: newPassword };
|
|
return this.updateUser(updatedUser);
|
|
}
|
|
|
|
// --- User Data Management ---
|
|
private getUserDbName = (
|
|
type: 'meds' | 'settings' | 'taken' | 'reminders',
|
|
userId: string
|
|
) => `${type}_${userId}`;
|
|
|
|
async getMedications(userId: string): Promise<Medication[]> {
|
|
return this.getDb<Medication>(this.getUserDbName('meds', userId));
|
|
}
|
|
|
|
async addMedication(
|
|
userId: string,
|
|
med: Omit<Medication, '_id' | '_rev'>
|
|
): Promise<Medication> {
|
|
const newMed = { ...med, _id: uuidv4() };
|
|
return this.putDoc<Medication>(this.getUserDbName('meds', userId), newMed);
|
|
}
|
|
|
|
async updateMedication(userId: string, med: Medication): Promise<Medication> {
|
|
return this.updateDocWithConflictResolution<Medication>(
|
|
this.getUserDbName('meds', userId),
|
|
med
|
|
);
|
|
}
|
|
|
|
async deleteMedication(userId: string, med: Medication): Promise<void> {
|
|
return this.deleteDoc<Medication>(this.getUserDbName('meds', userId), med);
|
|
}
|
|
|
|
async getCustomReminders(userId: string): Promise<CustomReminder[]> {
|
|
return this.getDb<CustomReminder>(this.getUserDbName('reminders', userId));
|
|
}
|
|
|
|
async addCustomReminder(
|
|
userId: string,
|
|
reminder: Omit<CustomReminder, '_id' | '_rev'>
|
|
): Promise<CustomReminder> {
|
|
const newReminder = { ...reminder, _id: uuidv4() };
|
|
return this.putDoc<CustomReminder>(
|
|
this.getUserDbName('reminders', userId),
|
|
newReminder
|
|
);
|
|
}
|
|
|
|
async updateCustomReminder(
|
|
userId: string,
|
|
reminder: CustomReminder
|
|
): Promise<CustomReminder> {
|
|
return this.updateDocWithConflictResolution<CustomReminder>(
|
|
this.getUserDbName('reminders', userId),
|
|
reminder
|
|
);
|
|
}
|
|
|
|
async deleteCustomReminder(
|
|
userId: string,
|
|
reminder: CustomReminder
|
|
): Promise<void> {
|
|
return this.deleteDoc<CustomReminder>(
|
|
this.getUserDbName('reminders', userId),
|
|
reminder
|
|
);
|
|
}
|
|
|
|
async getSettings(userId: string): Promise<UserSettings> {
|
|
const dbName = this.getUserDbName('settings', userId);
|
|
let settings = await this.getDoc<UserSettings>(dbName, userId);
|
|
if (!settings) {
|
|
settings = await this.putDoc<UserSettings>(dbName, {
|
|
_id: userId,
|
|
notificationsEnabled: true,
|
|
hasCompletedOnboarding: false,
|
|
});
|
|
}
|
|
return settings;
|
|
}
|
|
|
|
async updateSettings(
|
|
userId: string,
|
|
settings: UserSettings
|
|
): Promise<UserSettings> {
|
|
return this.updateDocWithConflictResolution<UserSettings>(
|
|
this.getUserDbName('settings', userId),
|
|
settings
|
|
);
|
|
}
|
|
|
|
async getTakenDoses(userId: string): Promise<TakenDoses> {
|
|
const dbName = this.getUserDbName('taken', userId);
|
|
let takenDoses = await this.getDoc<TakenDoses>(dbName, userId);
|
|
if (!takenDoses) {
|
|
takenDoses = await this.putDoc<TakenDoses>(dbName, {
|
|
_id: userId,
|
|
doses: {},
|
|
});
|
|
}
|
|
return takenDoses;
|
|
}
|
|
|
|
async updateTakenDoses(
|
|
userId: string,
|
|
takenDoses: TakenDoses
|
|
): Promise<TakenDoses> {
|
|
// Custom merge logic for taken doses to avoid overwriting recent updates
|
|
const mergeFn = (latest: TakenDoses, incoming: TakenDoses): TakenDoses => {
|
|
return {
|
|
...latest, // Use latest doc as the base
|
|
...incoming, // Apply incoming changes
|
|
doses: { ...latest.doses, ...incoming.doses }, // Specifically merge the doses object
|
|
_rev: latest._rev, // IMPORTANT: Use the latest revision for the update attempt
|
|
};
|
|
};
|
|
return this.updateDocWithConflictResolution<TakenDoses>(
|
|
this.getUserDbName('taken', userId),
|
|
takenDoses,
|
|
mergeFn
|
|
);
|
|
}
|
|
|
|
async deleteAllUserData(userId: string): Promise<void> {
|
|
await latency();
|
|
localStorage.removeItem(this.getUserDbName('meds', userId));
|
|
localStorage.removeItem(this.getUserDbName('settings', userId));
|
|
localStorage.removeItem(this.getUserDbName('taken', userId));
|
|
localStorage.removeItem(this.getUserDbName('reminders', userId));
|
|
}
|
|
}
|
|
|
|
export { CouchDBService };
|
|
export const dbService = new CouchDBService();
|