18 Commits

Author SHA1 Message Date
William Valentin 10d1de91fe refactor(logging): replace console usage with logger 2025-09-23 12:19:15 -07:00
William Valentin 16bd4a8b20 chore(makefile): add parity targets and seeding 2025-09-23 11:39:30 -07:00
William Valentin dec8c7b42e docs: add docker compose quickstart 2025-09-23 11:32:48 -07:00
William Valentin eb43766b21 docs: clarify run profiles and env setup 2025-09-23 11:29:39 -07:00
William Valentin de237fd997 refactor: centralize base64 encoding 2025-09-23 11:27:32 -07:00
William Valentin e3a924c0c6 feat(db): retry on document conflicts 2025-09-23 11:25:28 -07:00
William Valentin f9ccb50222 feat(db): index users by email 2025-09-23 11:23:45 -07:00
William Valentin fcfe2a38e2 feat(admin): replace alerts with toasts 2025-09-23 11:20:37 -07:00
William Valentin e9a662d1e2 feat(admin): add user search and sorting 2025-09-23 10:58:01 -07:00
William Valentin 7c712ae84b test(reminders): cover schedule edge cases 2025-09-23 10:54:33 -07:00
William Valentin 9bed793997 feat(reminders): validate frequency and time range 2025-09-23 10:53:12 -07:00
William Valentin 35dcae07e5 feat(accessibility): improve dose and reminder cards 2025-09-23 10:47:48 -07:00
William Valentin 2cb56d5f5f feat(medication): improve snooze timer handling 2025-09-23 10:45:18 -07:00
William Valentin 9b4ee116e6 test(schedule): cover dst transition logic 2025-09-23 10:39:35 -07:00
William Valentin e7dbe30763 feat(mail): clarify mailgun configuration feedback 2025-09-23 10:30:12 -07:00
William Valentin 71c37f4b7b test(auth): add token service coverage 2025-09-23 10:24:44 -07:00
William Valentin c1c8e28f01 refactor(logging): replace console usage in auth flows 2025-09-23 10:15:57 -07:00
William Valentin 6b6a44acef feat(auth): centralize token storage 2025-09-23 10:11:14 -07:00
37 changed files with 2307 additions and 388 deletions
+84 -28
View File
@@ -32,6 +32,7 @@ import {
AuthPage,
AvatarDropdown,
ChangePasswordModal,
ResetPasswordPage,
} from './components/auth';
import { AdminInterface } from './components/admin';
import {
@@ -62,6 +63,9 @@ import {
import { useUser } from './contexts/UserContext';
import { databaseService } from './services/database';
import { databaseSeeder } from './services/database.seeder';
import { logger } from './services/logging';
import { normalizeError } from './utils/error';
import { determineDoseStatus } from './utils/doseStatus';
const Header: React.FC<{
onAdd: () => void;
@@ -230,7 +234,9 @@ const MedicationScheduleApp: React.FC<{ user: User }> = ({ user }) => {
useEffect(() => {
// Don't try to fetch data if user._id is not available
if (!user._id) {
console.warn('Skipping data fetch: user._id is not available');
logger.ui.action('Skipping data fetch because user id is missing', {
userId: user._id,
});
return;
}
@@ -239,7 +245,9 @@ const MedicationScheduleApp: React.FC<{ user: User }> = ({ user }) => {
setIsLoading(true);
setError(null);
console.warn('Fetching data for user:', user._id);
logger.db.query('Fetching medication data for user', {
userId: user._id,
});
const [medsData, remindersData, takenDosesData, settingsData] =
await Promise.all([
@@ -249,9 +257,10 @@ const MedicationScheduleApp: React.FC<{ user: User }> = ({ user }) => {
databaseService.getUserSettings(user._id),
]);
console.warn('Data fetched successfully:', {
medications: medsData.length,
reminders: remindersData.length,
logger.db.query('Fetched user data successfully', {
userId: user._id,
medicationCount: medsData.length,
reminderCount: remindersData.length,
hasTakenDoses: !!takenDosesData,
hasSettings: !!settingsData,
});
@@ -266,8 +275,9 @@ const MedicationScheduleApp: React.FC<{ user: User }> = ({ user }) => {
}
} catch (e) {
setError('Failed to load your data. Please try again.');
console.error('Error loading user data:', e);
console.error('User object:', user);
logger.db.error('Error loading user data', normalizeError(e), {
userId: user._id,
});
} finally {
setIsLoading(false);
}
@@ -368,16 +378,35 @@ const MedicationScheduleApp: React.FC<{ user: User }> = ({ user }) => {
async (doseId: string) => {
if (!takenDosesDoc) return;
const newDoses = { ...takenDosesDoc.doses };
if (newDoses[doseId]) {
const wasTaken = Boolean(newDoses[doseId]);
if (wasTaken) {
delete newDoses[doseId];
} else {
newDoses[doseId] = new Date().toISOString();
}
const updatedDoc = await databaseService.updateTakenDoses({
...takenDosesDoc,
doses: newDoses,
});
setTakenDosesDoc(updatedDoc);
if (!wasTaken) {
setSnoozedDoses(prev => {
if (!prev[doseId]) {
return prev;
}
const updated = { ...prev };
delete updated[doseId];
return updated;
});
if (notificationTimers.current[doseId]) {
clearTimeout(notificationTimers.current[doseId]);
delete notificationTimers.current[doseId];
}
}
},
[takenDosesDoc]
);
@@ -395,13 +424,13 @@ const MedicationScheduleApp: React.FC<{ user: User }> = ({ user }) => {
}, []);
const getDoseStatus = useCallback(
(dose: Dose, doseTime: Date, now: Date): DoseStatus => {
if (takenDoses[dose.id]) return DoseStatus.TAKEN;
if (snoozedDoses[dose.id] && new Date(snoozedDoses[dose.id]) > now)
return DoseStatus.SNOOZED;
if (doseTime.getTime() < now.getTime()) return DoseStatus.MISSED;
return DoseStatus.UPCOMING;
},
(dose: Dose, doseTime: Date, now: Date): DoseStatus =>
determineDoseStatus({
takenAt: takenDoses[dose.id],
snoozedUntil: snoozedDoses[dose.id],
scheduledTime: doseTime,
now,
}),
[takenDoses, snoozedDoses]
);
@@ -413,15 +442,20 @@ const MedicationScheduleApp: React.FC<{ user: User }> = ({ user }) => {
const medication = medications.find(m => m._id === item.medicationId);
if (!medication) return null;
const snoozeString = snoozedDoses[item.id];
const snoozeDate = snoozeString ? new Date(snoozeString) : undefined;
const validSnooze =
snoozeDate && !Number.isNaN(snoozeDate.getTime())
? snoozeDate
: undefined;
return {
...item,
type: 'dose' as const,
medication,
status: getDoseStatus(item, item.scheduledTime, currentTime),
takenAt: takenDoses[item.id],
snoozedUntil: snoozedDoses[item.id]
? new Date(snoozedDoses[item.id])
: undefined,
snoozedUntil: validSnooze,
};
} else {
// It's a Custom Reminder
@@ -460,9 +494,10 @@ const MedicationScheduleApp: React.FC<{ user: User }> = ({ user }) => {
let timeToNotification = -1;
let notificationBody = '';
let notificationTitle = '';
let targetTime: Date | null = null;
if (item.type === 'dose' && item.status === DoseStatus.UPCOMING) {
timeToNotification = item.scheduledTime.getTime() - now.getTime();
targetTime = item.snoozedUntil ?? item.scheduledTime;
notificationTitle = 'Time for your medication!';
notificationBody = `${item.medication.name} (${item.medication.dosage})`;
} else if (
@@ -470,17 +505,21 @@ const MedicationScheduleApp: React.FC<{ user: User }> = ({ user }) => {
item.status === DoseStatus.SNOOZED &&
item.snoozedUntil
) {
timeToNotification = item.snoozedUntil.getTime() - now.getTime();
targetTime = item.snoozedUntil;
notificationTitle = 'Snoozed Medication Reminder';
notificationBody = `${item.medication.name} (${item.medication.dosage})`;
} else if (item.type === 'reminder' && item.scheduledTime > now) {
timeToNotification = item.scheduledTime.getTime() - now.getTime();
targetTime = item.scheduledTime;
notificationTitle = 'Reminder';
notificationBody = item.title;
}
if (targetTime) {
timeToNotification = targetTime.getTime() - now.getTime();
}
if (timeToNotification > 0) {
activeTimers[itemId] = setTimeout(() => {
const timerId = window.setTimeout(() => {
new Notification(notificationTitle, {
body: notificationBody,
tag: itemId,
@@ -488,16 +527,23 @@ const MedicationScheduleApp: React.FC<{ user: User }> = ({ user }) => {
if (item.type === 'dose' && item.status === DoseStatus.SNOOZED) {
setSnoozedDoses(prev => {
const newSnoozed = { ...prev };
delete newSnoozed[itemId];
newSnoozed[itemId] = new Date().toISOString();
return newSnoozed;
});
}
delete activeTimers[itemId];
}, timeToNotification) as unknown as number;
}, timeToNotification);
activeTimers[itemId] = timerId;
}
});
return () => Object.values(activeTimers).forEach(clearTimeout);
return () => {
Object.entries(activeTimers).forEach(([id, timer]) => {
clearTimeout(timer);
delete activeTimers[id];
});
};
}, [scheduleWithStatus, settings?.notificationsEnabled]);
const filteredSchedule = useMemo(
@@ -688,7 +734,11 @@ const MedicationScheduleApp: React.FC<{ user: User }> = ({ user }) => {
setSettings(updatedSettings);
setOnboardingOpen(false);
} catch (error) {
console.error('Failed to update onboarding status', error);
logger.ui.error(
'Failed to update onboarding status',
normalizeError(error),
{ userId: user._id }
);
setOnboardingOpen(false);
}
}
@@ -910,10 +960,10 @@ const App: React.FC = () => {
useEffect(() => {
const runSeeding = async () => {
try {
console.warn('🌱 Initializing database seeding...');
logger.db.query('Initializing database seeding');
await databaseSeeder.seedDatabase();
} catch (error) {
console.error(' Database seeding failed:', error);
logger.db.error('Database seeding failed', normalizeError(error));
}
};
@@ -929,6 +979,12 @@ const App: React.FC = () => {
}
if (!user) {
if (
typeof window !== 'undefined' &&
window.location.pathname === '/reset-password'
) {
return <ResetPasswordPage />;
}
return <AuthPage />;
}
+54 -1
View File
@@ -14,7 +14,12 @@ DOCKER_IMAGE ?= $(APP_NAME):latest
export
.PHONY: help install clean dev build test docker-build docker-buildx docker-run docker-clean info couchdb-up couchdb-down
.PHONY: help install clean \
dev preview build \
test test-watch test-coverage test-fast test-services test-integration \
lint lint-fix format format-check type-check \
docker-build docker-buildx docker-run docker-clean \
seed couchdb-up couchdb-down info
# Default target
.DEFAULT_GOAL := help
@@ -53,6 +58,10 @@ dev: ## Start development server
@echo "Starting $(APP_NAME) development server..."
@bun run dev
preview: ## Serve production build locally
@echo "Starting $(APP_NAME) preview server..."
@bun run preview
build: ## Build the application
@echo "Building $(APP_NAME) application..."
@bun run build
@@ -67,6 +76,44 @@ test-watch: ## Run unit tests in watch mode
@echo "Running $(APP_NAME) tests in watch mode..."
@bun run test:watch
test-coverage: ## Run tests with coverage report
@echo "Running $(APP_NAME) tests with coverage..."
@bun run test:coverage
test-fast: ## Run fast unit test subset
@echo "Running fast unit test subset..."
@bun run test:fast
test-services: ## Run service layer tests only
@echo "Running service layer tests..."
@bun run test:services
test-integration: ## Run integration tests
@echo "Running integration tests..."
@bun run test:integration
##@ Quality
lint: ## Run ESLint
@echo "Linting $(APP_NAME)..."
@bun run lint
lint-fix: ## Run ESLint with autofix
@echo "Linting $(APP_NAME) with auto-fix..."
@bun run lint:fix
format: ## Format code with Prettier
@echo "Formatting $(APP_NAME) code..."
@bun run format
format-check: ## Check code formatting without writing changes
@echo "Checking $(APP_NAME) formatting..."
@bun run format:check
type-check: ## Run TypeScript type checking
@echo "Type-checking $(APP_NAME)..."
@bun run type-check
##@ Docker
docker-build: ## Build Docker image for local development
@@ -102,6 +149,12 @@ docker-clean: ## Clean Docker resources and containers
@docker image prune -f 2>/dev/null || true
@docker container prune -f 2>/dev/null || true
##@ Database
seed: ## Seed default admin user into CouchDB
@echo "Seeding default admin account..."
@bun run seed
##@ Test Services
couchdb-up: ## Start local CouchDB for integration tests
+21 -1
View File
@@ -39,6 +39,16 @@ A modern, secure web application for managing medication schedules and reminders
- **Progress Tracking** over time
- **Export Capabilities** for healthcare providers
## 🧪 Run Profiles
| Profile | Purpose | How to run | Configuration |
| --------------- | ------------------------------------------------------ | ----------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Development** | Fast local iteration with hot reload and mock services | `bun run dev` | Copy `.env.example` to `.env.local` for local overrides. CouchDB can be mocked (`VITE_COUCHDB_URL=mock`) or pointed to a dev instance. |
| **Testing** | Unit and integration validation in CI/stage | `bun run test` · `bun run test:watch` · `bun run test:coverage` | Tests run against the mock database strategy by default. No extra environment variables required. |
| **Production** | Hardened build served via Docker/Reverse proxy | `bun run build && bun run preview` or `docker compose up --build` | Populate `.env` with production credentials (CouchDB, Mailgun, OAuth). Review [`docs/setup/ENVIRONMENT_VARIABLES.md`](docs/setup/ENVIRONMENT_VARIABLES.md) for required keys. |
> ️ **Tip:** `.env.example` enumerates every variable consumed by the app. For local development prefer `.env.local` (ignored by Git) to avoid accidentally committing secrets.
### 🎨 **User Experience**
- **Responsive Design** for mobile and desktop
@@ -226,7 +236,17 @@ The application automatically selects the appropriate database strategy:
## 🐳 Docker Development
### **Build and Run**
### **Docker Compose Quickstart**
```bash
docker compose up --build
```
- Serves the production build at [http://localhost:8080](http://localhost:8080)
- Spins up CouchDB at [http://localhost:5984](http://localhost:5984) using credentials from `.env`
- Applies CORS settings from `couchdb-config/cors.ini` (update the allowed `origins` for custom domains)
### **Manual Build and Run**
```bash
# Build Docker image
+469 -220
View File
@@ -1,8 +1,9 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useMemo } from 'react';
import { User, UserRole } from '../../types';
import { AccountStatus } from '../../services/auth/auth.constants';
import { databaseService } from '../../services/database';
import { useUser } from '../../contexts/UserContext';
import { logger } from '../../services/logging';
interface AdminInterfaceProps {
onClose: () => void;
@@ -15,61 +16,114 @@ const AdminInterface: React.FC<AdminInterfaceProps> = ({ onClose }) => {
const [error, setError] = useState('');
const [selectedUser, setSelectedUser] = useState<User | null>(null);
const [newPassword, setNewPassword] = useState('');
const [userPendingDeletion, setUserPendingDeletion] = useState<User | null>(
null
);
const [isDeletingUser, setIsDeletingUser] = useState(false);
const [toasts, setToasts] = useState<
Array<{ id: string; message: string; tone: 'success' | 'error' | 'info' }>
>([]);
const [searchTerm, setSearchTerm] = useState('');
const [sortField, setSortField] = useState<
'createdAt' | 'status' | 'role' | 'username'
>('createdAt');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('desc');
const createToastId = () =>
typeof crypto !== 'undefined' && crypto.randomUUID
? crypto.randomUUID()
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
const removeToast = (id: string) => {
setToasts(prev => prev.filter(toast => toast.id !== id));
};
const pushToast = (
message: string,
tone: 'success' | 'error' | 'info' = 'info'
) => {
const id = createToastId();
setToasts(prev => [...prev, { id, message, tone }]);
window.setTimeout(() => removeToast(id), 4000);
};
const toastStyles: Record<'success' | 'error' | 'info', string> = {
success: 'bg-green-50 border border-green-200 text-green-800',
error: 'bg-red-50 border border-red-200 text-red-800',
info: 'bg-blue-50 border border-blue-200 text-blue-800',
};
useEffect(() => {
loadUsers();
}, []);
const loadUsers = async () => {
setLoading(true);
setError('');
try {
const users = await databaseService.getAllUsers();
setUsers(users);
} catch (error) {
setError('Failed to load users');
console.error('Error loading users:', error);
logger.ui.error('Error loading users', error as Error);
pushToast('Failed to load users', 'error');
} finally {
setLoading(false);
}
};
const handleSuspendUser = async (userId: string) => {
const handleSuspendUser = async (user: User) => {
try {
await databaseService.suspendUser(userId);
await databaseService.suspendUser(user._id);
pushToast(`${user.username} suspended`, 'info');
await loadUsers();
} catch (error) {
setError('Failed to suspend user');
console.error('Error suspending user:', error);
logger.ui.error('Error suspending user', error as Error);
pushToast('Failed to suspend user', 'error');
}
};
const handleActivateUser = async (userId: string) => {
const handleActivateUser = async (user: User) => {
try {
await databaseService.activateUser(userId);
await databaseService.activateUser(user._id);
pushToast(`${user.username} reactivated`, 'success');
await loadUsers();
} catch (error) {
setError('Failed to activate user');
console.error('Error activating user:', error);
logger.ui.error('Error activating user', error as Error);
pushToast('Failed to activate user', 'error');
}
};
const handleDeleteUser = async (userId: string) => {
if (
!confirm(
'Are you sure you want to delete this user? This action cannot be undone.'
)
) {
return;
}
const confirmDeleteUser = (user: User) => {
setUserPendingDeletion(user);
setError('');
};
const executeDeleteUser = async () => {
if (!userPendingDeletion) return;
setIsDeletingUser(true);
try {
await databaseService.deleteUser(userId);
await databaseService.deleteUser(userPendingDeletion._id);
pushToast(`${userPendingDeletion.username} deleted`, 'info');
await loadUsers();
} catch (error) {
setError('Failed to delete user');
console.error('Error deleting user:', error);
logger.ui.error('Error deleting user', error as Error);
pushToast('Failed to delete user', 'error');
} finally {
setIsDeletingUser(false);
setUserPendingDeletion(null);
}
};
const closeDeleteDialog = () => {
if (isDeletingUser) return;
setUserPendingDeletion(null);
};
const handleChangePassword = async (userId: string) => {
if (!newPassword || newPassword.length < 6) {
setError('Password must be at least 6 characters long');
@@ -81,10 +135,11 @@ const AdminInterface: React.FC<AdminInterfaceProps> = ({ onClose }) => {
setNewPassword('');
setSelectedUser(null);
setError('');
alert('Password changed successfully');
pushToast('Password changed successfully', 'success');
} catch (error) {
setError('Failed to change password');
console.error('Error changing password:', error);
logger.ui.error('Error changing password', error as Error);
pushToast('Failed to change password', 'error');
}
};
@@ -107,6 +162,88 @@ const AdminInterface: React.FC<AdminInterfaceProps> = ({ onClose }) => {
: 'text-blue-600 bg-blue-100';
};
const statusPriority: Record<AccountStatus, number> = {
[AccountStatus.ACTIVE]: 0,
[AccountStatus.PENDING]: 1,
[AccountStatus.SUSPENDED]: 2,
};
const rolePriority: Record<UserRole, number> = {
[UserRole.ADMIN]: 0,
[UserRole.USER]: 1,
};
const getStatusPriority = (status?: AccountStatus) =>
statusPriority[status as AccountStatus] ?? Number.MAX_SAFE_INTEGER;
const getRolePriority = (role?: UserRole) =>
rolePriority[role as UserRole] ?? Number.MAX_SAFE_INTEGER;
const sortedUsers = useMemo(() => {
const copy = [...users];
copy.sort((a, b) => {
let result = 0;
switch (sortField) {
case 'status':
result = getStatusPriority(a.status) - getStatusPriority(b.status);
break;
case 'role':
result = getRolePriority(a.role) - getRolePriority(b.role);
break;
case 'username':
result = (a.username || '').localeCompare(
b.username || '',
undefined,
{
sensitivity: 'base',
}
);
break;
case 'createdAt':
default: {
const timeA = a.createdAt ? new Date(a.createdAt).getTime() : 0;
const timeB = b.createdAt ? new Date(b.createdAt).getTime() : 0;
result = timeA - timeB;
break;
}
}
if (result === 0) {
result = (a.username || '').localeCompare(b.username || '', undefined, {
sensitivity: 'base',
});
}
if (result === 0) {
result = (a.email || '').localeCompare(b.email || '', undefined, {
sensitivity: 'base',
});
}
return sortDirection === 'asc' ? result : -result;
});
return copy;
}, [users, sortField, sortDirection]);
const filteredUsers = useMemo(() => {
if (!searchTerm.trim()) {
return sortedUsers;
}
const query = searchTerm.trim().toLowerCase();
return sortedUsers.filter(user => {
const username = user.username?.toLowerCase() ?? '';
const email = user.email?.toLowerCase() ?? '';
return username.includes(query) || email.includes(query);
});
}, [sortedUsers, searchTerm]);
const visibleUsersLabel = searchTerm.trim()
? `${filteredUsers.length} of ${users.length} users`
: `${filteredUsers.length} users`;
if (currentUser?.role !== UserRole.ADMIN) {
return (
<div className='fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50'>
@@ -127,234 +264,346 @@ const AdminInterface: React.FC<AdminInterfaceProps> = ({ onClose }) => {
}
return (
<div className='fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4'>
<div className='bg-white dark:bg-slate-800 rounded-lg w-full max-w-6xl max-h-[90vh] overflow-hidden'>
<div className='p-6 border-b border-slate-200 dark:border-slate-600'>
<div className='flex justify-between items-center'>
<h2 className='text-2xl font-bold text-slate-800 dark:text-slate-100'>
Admin Interface
</h2>
<>
<div className='fixed top-4 right-4 z-[70] space-y-2'>
{toasts.map(toast => (
<div
key={toast.id}
className={`px-4 py-3 rounded-lg shadow-lg flex items-start justify-between gap-3 ${toastStyles[toast.tone]}`}
role='status'
aria-live='polite'
>
<span className='text-sm font-medium'>{toast.message}</span>
<button
onClick={onClose}
className='text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200'
onClick={() => removeToast(toast.id)}
className='text-sm font-semibold text-slate-600 hover:text-slate-800 dark:text-slate-200 dark:hover:text-slate-50'
aria-label='Dismiss notification'
>
<svg
className='w-6 h-6'
fill='none'
stroke='currentColor'
viewBox='0 0 24 24'
>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth={2}
d='M6 18L18 6M6 6l12 12'
/>
</svg>
×
</button>
</div>
</div>
<div className='p-6 overflow-y-auto max-h-[calc(90vh-120px)]'>
{error && (
<div className='bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4'>
{error}
</div>
)}
{loading ? (
<div className='text-center py-8'>
<div className='animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-600 mx-auto'></div>
<p className='mt-2 text-slate-600 dark:text-slate-300'>
Loading users...
</p>
</div>
) : (
<div className='space-y-4'>
<div className='flex justify-between items-center'>
<h3 className='text-lg font-semibold text-slate-800 dark:text-slate-100'>
User Management ({users.length} users)
</h3>
<button
onClick={loadUsers}
className='bg-indigo-600 hover:bg-indigo-700 text-white px-4 py-2 rounded-md text-sm'
))}
</div>
<div className='fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4'>
<div className='bg-white dark:bg-slate-800 rounded-lg w-full max-w-6xl max-h-[90vh] overflow-hidden'>
<div className='p-6 border-b border-slate-200 dark:border-slate-600'>
<div className='flex justify-between items-center'>
<h2 className='text-2xl font-bold text-slate-800 dark:text-slate-100'>
Admin Interface
</h2>
<button
onClick={onClose}
className='text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200'
>
<svg
className='w-6 h-6'
fill='none'
stroke='currentColor'
viewBox='0 0 24 24'
>
Refresh
</button>
<path
strokeLinecap='round'
strokeLinejoin='round'
strokeWidth={2}
d='M6 18L18 6M6 6l12 12'
/>
</svg>
</button>
</div>
</div>
<div className='p-6 overflow-y-auto max-h-[calc(90vh-120px)]'>
{error && (
<div className='bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded mb-4'>
{error}
</div>
)}
<div className='overflow-x-auto'>
<table className='min-w-full bg-white dark:bg-slate-700 rounded-lg overflow-hidden'>
<thead className='bg-slate-50 dark:bg-slate-600'>
<tr>
<th className='px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-300 uppercase tracking-wider'>
User
</th>
<th className='px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-300 uppercase tracking-wider'>
Email
</th>
<th className='px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-300 uppercase tracking-wider'>
Status
</th>
<th className='px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-300 uppercase tracking-wider'>
Role
</th>
<th className='px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-300 uppercase tracking-wider'>
Created
</th>
<th className='px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-300 uppercase tracking-wider'>
Actions
</th>
</tr>
</thead>
<tbody className='divide-y divide-slate-200 dark:divide-slate-600'>
{users.map(user => (
<tr
key={user._id}
className='hover:bg-slate-50 dark:hover:bg-slate-600'
{loading ? (
<div className='text-center py-8'>
<div className='animate-spin rounded-full h-8 w-8 border-b-2 border-indigo-600 mx-auto'></div>
<p className='mt-2 text-slate-600 dark:text-slate-300'>
Loading users...
</p>
</div>
) : (
<div className='space-y-4'>
<div className='flex justify-between items-center'>
<h3 className='text-lg font-semibold text-slate-800 dark:text-slate-100'>
User Management ({visibleUsersLabel})
</h3>
<button
onClick={loadUsers}
className='bg-indigo-600 hover:bg-indigo-700 text-white px-4 py-2 rounded-md text-sm'
>
Refresh
</button>
</div>
<div className='overflow-x-auto'>
<div className='flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between mb-4'>
<div className='w-full sm:w-72'>
<label htmlFor='admin-search' className='sr-only'>
Search users
</label>
<input
id='admin-search'
type='search'
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
placeholder='Search by username or email'
className='w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 text-sm bg-white dark:bg-slate-700 dark:border-slate-600 dark:text-slate-100'
/>
</div>
<div className='flex items-center gap-2'>
<label
htmlFor='admin-sort'
className='text-sm text-slate-600 dark:text-slate-300'
>
<td className='px-4 py-4'>
<div className='flex items-center'>
{user.avatar ? (
<img
src={user.avatar}
alt={user.username}
className='w-8 h-8 rounded-full mr-3'
/>
) : (
<div className='w-8 h-8 bg-indigo-600 rounded-full flex items-center justify-center mr-3'>
<span className='text-white text-sm font-medium'>
{user.username.charAt(0).toUpperCase()}
</span>
</div>
)}
<div>
<div className='text-sm font-medium text-slate-900 dark:text-slate-100'>
{user.username}
</div>
<div className='text-sm text-slate-500 dark:text-slate-400'>
ID: {user._id.slice(-8)}
</div>
</div>
</div>
</td>
<td className='px-4 py-4 text-sm text-slate-900 dark:text-slate-100'>
{user.email}
{user.emailVerified && (
<span className='ml-2 text-green-600'>✓</span>
)}
</td>
<td className='px-4 py-4'>
<span
className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getStatusColor(user.status)}`}
Sort by
</label>
<select
id='admin-sort'
value={sortField}
onChange={e =>
setSortField(
e.target.value as
| 'createdAt'
| 'status'
| 'role'
| 'username'
)
}
className='px-3 py-2 border border-slate-300 rounded-md text-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 bg-white dark:bg-slate-700 dark:border-slate-600 dark:text-slate-100'
>
<option value='createdAt'>Created Date</option>
<option value='status'>Status</option>
<option value='role'>Role</option>
<option value='username'>Name</option>
</select>
<button
type='button'
onClick={() =>
setSortDirection(prev =>
prev === 'asc' ? 'desc' : 'asc'
)
}
className='px-3 py-2 text-sm border border-slate-300 rounded-md bg-white hover:bg-slate-100 dark:bg-slate-700 dark:border-slate-600 dark:text-slate-100 dark:hover:bg-slate-600'
aria-label={`Toggle sort direction. Currently ${
sortDirection === 'asc' ? 'ascending' : 'descending'
}`}
>
{sortDirection === 'asc' ? 'Asc ' : 'Desc '}
</button>
</div>
</div>
{filteredUsers.length === 0 ? (
<p className='text-center text-sm text-slate-600 dark:text-slate-300 py-6'>
No users match the current filters.
</p>
) : (
<table className='min-w-full bg-white dark:bg-slate-700 rounded-lg overflow-hidden'>
<thead className='bg-slate-50 dark:bg-slate-600'>
<tr>
<th className='px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-300 uppercase tracking-wider'>
User
</th>
<th className='px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-300 uppercase tracking-wider'>
Email
</th>
<th className='px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-300 uppercase tracking-wider'>
Status
</th>
<th className='px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-300 uppercase tracking-wider'>
Role
</th>
<th className='px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-300 uppercase tracking-wider'>
Created
</th>
<th className='px-4 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-300 uppercase tracking-wider'>
Actions
</th>
</tr>
</thead>
<tbody className='divide-y divide-slate-200 dark:divide-slate-600'>
{filteredUsers.map(user => (
<tr
key={user._id}
className='hover:bg-slate-50 dark:hover:bg-slate-600'
>
{user.status || 'Unknown'}
</span>
</td>
<td className='px-4 py-4'>
<span
className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getRoleColor(user.role)}`}
>
{user.role || 'USER'}
</span>
</td>
<td className='px-4 py-4 text-sm text-slate-500 dark:text-slate-400'>
{user.createdAt
? new Date(user.createdAt).toLocaleDateString()
: 'Unknown'}
</td>
<td className='px-4 py-4'>
<div className='flex space-x-2'>
{user.status === AccountStatus.ACTIVE ? (
<button
onClick={() => handleSuspendUser(user._id)}
className='text-red-600 hover:text-red-800 text-xs'
disabled={user._id === currentUser?._id}
<td className='px-4 py-4'>
<div className='flex items-center'>
{user.avatar ? (
<img
src={user.avatar}
alt={user.username}
className='w-8 h-8 rounded-full mr-3'
/>
) : (
<div className='w-8 h-8 bg-indigo-600 rounded-full flex items-center justify-center mr-3'>
<span className='text-white text-sm font-medium'>
{user.username.charAt(0).toUpperCase()}
</span>
</div>
)}
<div>
<div className='text-sm font-medium text-slate-900 dark:text-slate-100'>
{user.username}
</div>
<div className='text-sm text-slate-500 dark:text-slate-400'>
ID: {user._id.slice(-8)}
</div>
</div>
</div>
</td>
<td className='px-4 py-4 text-sm text-slate-900 dark:text-slate-100'>
{user.email}
{user.emailVerified && (
<span className='ml-2 text-green-600'>✓</span>
)}
</td>
<td className='px-4 py-4'>
<span
className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getStatusColor(user.status)}`}
>
Suspend
</button>
) : (
<button
onClick={() => handleActivateUser(user._id)}
className='text-green-600 hover:text-green-800 text-xs'
{user.status || 'Unknown'}
</span>
</td>
<td className='px-4 py-4'>
<span
className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getRoleColor(user.role)}`}
>
Activate
</button>
)}
{user.role || 'USER'}
</span>
</td>
<td className='px-4 py-4 text-sm text-slate-500 dark:text-slate-400'>
{user.createdAt
? new Date(user.createdAt).toLocaleDateString()
: 'Unknown'}
</td>
<td className='px-4 py-4'>
<div className='flex space-x-2'>
{user.status === AccountStatus.ACTIVE ? (
<button
onClick={() => handleSuspendUser(user)}
className='text-red-600 hover:text-red-800 text-xs'
disabled={user._id === currentUser?._id}
>
Suspend
</button>
) : (
<button
onClick={() => handleActivateUser(user)}
className='text-green-600 hover:text-green-800 text-xs'
>
Activate
</button>
)}
{user.password && (
<button
onClick={() => setSelectedUser(user)}
className='text-blue-600 hover:text-blue-800 text-xs'
>
Change Password
</button>
)}
{user.password && (
<button
onClick={() => setSelectedUser(user)}
className='text-blue-600 hover:text-blue-800 text-xs'
>
Change Password
</button>
)}
<button
onClick={() => handleDeleteUser(user._id)}
className='text-red-600 hover:text-red-800 text-xs'
disabled={
user._id === currentUser?._id ||
user.role === UserRole.ADMIN
}
>
Delete
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
<button
onClick={() => confirmDeleteUser(user)}
className='text-red-600 hover:text-red-800 text-xs'
disabled={
user._id === currentUser?._id ||
user.role === UserRole.ADMIN
}
>
Delete
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</div>
)}
</div>
{/* Password Change Modal */}
{selectedUser && (
<div className='fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-60'>
<div className='bg-white dark:bg-slate-800 rounded-lg p-6 max-w-md w-full mx-4'>
<h3 className='text-lg font-semibold text-slate-800 dark:text-slate-100 mb-4'>
Change Password for {selectedUser.username}
</h3>
<div className='space-y-4'>
<div>
<label className='block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2'>
New Password
</label>
<input
type='password'
value={newPassword}
onChange={e => setNewPassword(e.target.value)}
className='w-full px-3 py-2 border border-slate-300 rounded-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 dark:bg-slate-700 dark:border-slate-600 dark:text-white'
placeholder='Enter new password (min 6 characters)'
/>
</div>
<div className='flex space-x-3'>
<button
onClick={() => handleChangePassword(selectedUser._id)}
className='flex-1 bg-indigo-600 hover:bg-indigo-700 text-white font-medium py-2 px-4 rounded-md'
>
Change Password
</button>
<button
onClick={() => {
setSelectedUser(null);
setNewPassword('');
}}
className='flex-1 bg-slate-300 hover:bg-slate-400 text-slate-700 font-medium py-2 px-4 rounded-md'
>
Cancel
</button>
</div>
</div>
</div>
</div>
)}
</div>
{/* Password Change Modal */}
{selectedUser && (
<div className='fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-60'>
<div className='bg-white dark:bg-slate-800 rounded-lg p-6 max-w-md w-full mx-4'>
<h3 className='text-lg font-semibold text-slate-800 dark:text-slate-100 mb-4'>
Change Password for {selectedUser.username}
</h3>
<div className='space-y-4'>
<div>
<label className='block text-sm font-medium text-slate-700 dark:text-slate-300 mb-2'>
New Password
</label>
<input
type='password'
value={newPassword}
onChange={e => setNewPassword(e.target.value)}
className='w-full px-3 py-2 border border-slate-300 rounded-md focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 dark:bg-slate-700 dark:border-slate-600 dark:text-white'
placeholder='Enter new password (min 6 characters)'
/>
</div>
{userPendingDeletion && (
<div className='fixed inset-0 bg-black bg-opacity-40 flex items-center justify-center z-[65] p-4'>
<div className='bg-white dark:bg-slate-800 rounded-lg p-6 max-w-md w-full'>
<h3 className='text-lg font-semibold text-slate-800 dark:text-slate-100 mb-2'>
Delete {userPendingDeletion.username}?
</h3>
<p className='text-sm text-slate-600 dark:text-slate-300 mb-4'>
This action cannot be undone. The user's data will be
permanently removed.
</p>
<div className='flex space-x-3'>
<button
onClick={() => handleChangePassword(selectedUser._id)}
className='flex-1 bg-indigo-600 hover:bg-indigo-700 text-white font-medium py-2 px-4 rounded-md'
onClick={executeDeleteUser}
disabled={isDeletingUser}
className='flex-1 bg-red-600 hover:bg-red-700 text-white font-medium py-2 px-4 rounded-md disabled:opacity-50'
>
Change Password
{isDeletingUser ? 'Deleting...' : 'Delete'}
</button>
<button
onClick={() => {
setSelectedUser(null);
setNewPassword('');
}}
className='flex-1 bg-slate-300 hover:bg-slate-400 text-slate-700 font-medium py-2 px-4 rounded-md'
onClick={closeDeleteDialog}
disabled={isDeletingUser}
className='flex-1 bg-slate-200 hover:bg-slate-300 text-slate-700 font-medium py-2 px-4 rounded-md dark:bg-slate-700 dark:text-slate-200 dark:hover:bg-slate-600 disabled:opacity-50'
>
Cancel
</button>
</div>
</div>
</div>
</div>
)}
)}
</div>
</div>
</div>
</>
);
};
+2 -1
View File
@@ -1,6 +1,7 @@
import React, { useState, useEffect, useRef } from 'react';
import { Medication, Frequency } from '../../types';
import { medicationIcons } from '../icons/Icons';
import { logger } from '../../services/logging';
interface AddMedicationModalProps {
isOpen: boolean;
@@ -67,7 +68,7 @@ const AddMedicationModal: React.FC<AddMedicationModalProps> = ({
icon,
});
} catch (error) {
console.error('Failed to add medication', error);
logger.ui.error('Failed to add medication', error as Error);
alert('There was an error saving your medication. Please try again.');
setIsSaving(false);
}
+46 -2
View File
@@ -59,6 +59,13 @@ const statusStyles = {
},
};
const statusLabels: Record<DoseStatus, string> = {
[DoseStatus.UPCOMING]: 'Upcoming dose',
[DoseStatus.TAKEN]: 'Dose taken',
[DoseStatus.MISSED]: 'Dose missed',
[DoseStatus.SNOOZED]: 'Dose snoozed',
};
const DoseCard: React.FC<DoseCardProps> = ({
dose,
medication,
@@ -68,6 +75,7 @@ const DoseCard: React.FC<DoseCardProps> = ({
snoozedUntil,
}) => {
const styles = statusStyles[status];
const statusLabel = statusLabels[status];
const timeString = dose.scheduledTime.toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
@@ -85,17 +93,48 @@ const DoseCard: React.FC<DoseCardProps> = ({
})
: '';
const MedicationIcon = getMedicationIcon(medication.icon);
const cardTitleId = `dose-${dose.id}-title`;
const statusId = `dose-${dose.id}-status`;
const handleCardKeyDown = (
event: React.KeyboardEvent<HTMLLIElement>
): void => {
if (event.target !== event.currentTarget) {
return;
}
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onToggleDose(dose.id);
}
if (
(event.key.toLowerCase() === 's' || event.key === 'S') &&
status === DoseStatus.UPCOMING
) {
event.preventDefault();
onSnooze(dose.id);
}
};
return (
<li
className={`shadow-md rounded-lg p-4 flex flex-col justify-between transition-all duration-300 ${styles.bg} ${styles.ring} ring-4 ring-transparent border border-slate-200 dark:border-slate-700`}
role='group'
tabIndex={0}
onKeyDown={handleCardKeyDown}
aria-labelledby={cardTitleId}
aria-describedby={statusId}
className={`shadow-md rounded-lg p-4 flex flex-col justify-between transition-all duration-300 ${styles.bg} ${styles.ring} ring-4 ring-transparent border border-slate-200 dark:border-slate-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-400 dark:focus:ring-offset-slate-900`}
>
<div>
<div className='flex justify-between items-start'>
<div className='flex items-center space-x-3'>
<MedicationIcon className='w-7 h-7 text-indigo-500 dark:text-indigo-400 flex-shrink-0' />
<div>
<h4 className='font-bold text-lg text-slate-800 dark:text-slate-100'>
<h4
id={cardTitleId}
className='font-bold text-lg text-slate-800 dark:text-slate-100'
>
{medication.name}
</h4>
<p className='text-slate-600 dark:text-slate-300'>
@@ -106,9 +145,14 @@ const DoseCard: React.FC<DoseCardProps> = ({
{styles.icon}
</div>
<div
id={statusId}
role='status'
aria-live='polite'
aria-atomic='true'
className={`flex items-center space-x-2 mt-4 font-semibold text-lg ${styles.text}`}
>
<ClockIcon className='w-5 h-5' />
<span className='sr-only'>{statusLabel}</span>
<span>{timeString}</span>
</div>
@@ -1,6 +1,7 @@
import React, { useState, useEffect, useRef } from 'react';
import { Medication, Frequency } from '../../types';
import { medicationIcons } from '../icons/Icons';
import { logger } from '../../services/logging';
interface EditMedicationModalProps {
isOpen: boolean;
@@ -72,7 +73,7 @@ const EditMedicationModal: React.FC<EditMedicationModalProps> = ({
icon,
});
} catch (error) {
console.error('Failed to update medication', error);
logger.ui.error('Failed to update medication', error as Error);
alert('There was an error updating your medication. Please try again.');
setIsSaving(false);
}
+71 -4
View File
@@ -1,6 +1,12 @@
import React, { useState, useEffect, useRef } from 'react';
import { CustomReminder } from '../../types';
import { reminderIcons } from '../icons/Icons';
import {
MIN_REMINDER_FREQUENCY_MINUTES,
MAX_REMINDER_FREQUENCY_MINUTES,
validateReminderInputs,
} from './reminderValidation';
import { logger } from '../../services/logging';
interface AddReminderModalProps {
isOpen: boolean;
@@ -19,9 +25,21 @@ const AddReminderModal: React.FC<AddReminderModalProps> = ({
const [startTime, setStartTime] = useState('09:00');
const [endTime, setEndTime] = useState('17:00');
const [isSaving, setIsSaving] = useState(false);
const [errors, setErrors] = useState<{
frequency?: string;
timeRange?: string;
}>({});
const titleInputRef = useRef<HTMLInputElement>(null);
const validate = () => {
return validateReminderInputs({
frequencyMinutes,
startTime,
endTime,
});
};
useEffect(() => {
if (isOpen) {
setTitle('');
@@ -30,14 +48,26 @@ const AddReminderModal: React.FC<AddReminderModalProps> = ({
setStartTime('09:00');
setEndTime('17:00');
setIsSaving(false);
setErrors({});
setTimeout(() => titleInputRef.current?.focus(), 100);
}
}, [isOpen]);
useEffect(() => {
if (!isOpen) return;
setErrors(validate());
}, [isOpen, frequencyMinutes, startTime, endTime]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!title || isSaving) return;
const validation = validate();
setErrors(validation);
if (Object.keys(validation).length > 0) {
return;
}
setIsSaving(true);
try {
await onAdd({
@@ -48,7 +78,7 @@ const AddReminderModal: React.FC<AddReminderModalProps> = ({
endTime,
});
} catch (error) {
console.error('Failed to add reminder', error);
logger.ui.error('Failed to add reminder', error as Error);
alert('There was an error saving your reminder. Please try again.');
} finally {
setIsSaving(false);
@@ -125,11 +155,30 @@ const AddReminderModal: React.FC<AddReminderModalProps> = ({
id='rem-frequency'
value={frequencyMinutes}
onChange={e =>
setFrequencyMinutes(parseInt(e.target.value, 10))
setFrequencyMinutes(
Number.isNaN(parseInt(e.target.value, 10))
? 0
: parseInt(e.target.value, 10)
)
}
min='1'
min={MIN_REMINDER_FREQUENCY_MINUTES}
max={MAX_REMINDER_FREQUENCY_MINUTES}
aria-invalid={Boolean(errors.frequency)}
aria-describedby='rem-frequency-help'
className='mt-1 block w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm bg-white dark:bg-slate-700 dark:border-slate-600'
/>
<p
id='rem-frequency-help'
className={`mt-1 text-sm ${
errors.frequency
? 'text-red-600 dark:text-red-400'
: 'text-slate-500 dark:text-slate-400'
}`}
>
{errors.frequency
? errors.frequency
: `Minimum ${MIN_REMINDER_FREQUENCY_MINUTES} minutes, maximum ${MAX_REMINDER_FREQUENCY_MINUTES} minutes.`}
</p>
</div>
<div className='grid grid-cols-2 gap-4'>
@@ -146,6 +195,10 @@ const AddReminderModal: React.FC<AddReminderModalProps> = ({
value={startTime}
onChange={e => setStartTime(e.target.value)}
required
aria-invalid={Boolean(errors.timeRange)}
aria-describedby={
errors.timeRange ? 'rem-time-help' : undefined
}
className='mt-1 block w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm bg-white dark:bg-slate-700 dark:border-slate-600'
/>
</div>
@@ -162,10 +215,22 @@ const AddReminderModal: React.FC<AddReminderModalProps> = ({
value={endTime}
onChange={e => setEndTime(e.target.value)}
required
aria-invalid={Boolean(errors.timeRange)}
aria-describedby={
errors.timeRange ? 'rem-time-help' : undefined
}
className='mt-1 block w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm bg-white dark:bg-slate-700 dark:border-slate-600'
/>
</div>
</div>
{errors.timeRange && (
<p
id='rem-time-help'
className='text-sm text-red-600 dark:text-red-400'
>
{errors.timeRange}
</p>
)}
</div>
<div className='px-6 py-4 bg-slate-50 dark:bg-slate-700/50 flex justify-end space-x-3 rounded-b-lg border-t border-slate-200 dark:border-slate-700'>
<button
@@ -178,7 +243,9 @@ const AddReminderModal: React.FC<AddReminderModalProps> = ({
</button>
<button
type='submit'
disabled={isSaving}
disabled={
isSaving || Boolean(errors.frequency || errors.timeRange)
}
className='px-4 py-2 text-sm font-medium text-white bg-indigo-600 border border-transparent rounded-md shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed flex items-center dark:focus:ring-offset-slate-800'
>
{isSaving ? 'Adding...' : 'Add Reminder'}
+69 -6
View File
@@ -1,6 +1,12 @@
import React, { useState, useEffect, useRef } from 'react';
import { CustomReminder } from '../../types';
import { reminderIcons } from '../icons/Icons';
import {
MIN_REMINDER_FREQUENCY_MINUTES,
MAX_REMINDER_FREQUENCY_MINUTES,
validateReminderInputs,
} from './reminderValidation';
import { logger } from '../../services/logging';
interface EditReminderModalProps {
isOpen: boolean;
@@ -21,9 +27,20 @@ const EditReminderModal: React.FC<EditReminderModalProps> = ({
const [startTime, setStartTime] = useState('09:00');
const [endTime, setEndTime] = useState('17:00');
const [isSaving, setIsSaving] = useState(false);
const [errors, setErrors] = useState<{
frequency?: string;
timeRange?: string;
}>({});
const titleInputRef = useRef<HTMLInputElement>(null);
const validate = () =>
validateReminderInputs({
frequencyMinutes,
startTime,
endTime,
});
useEffect(() => {
if (isOpen && reminder) {
setTitle(reminder.title);
@@ -32,14 +49,26 @@ const EditReminderModal: React.FC<EditReminderModalProps> = ({
setStartTime(reminder.startTime);
setEndTime(reminder.endTime);
setIsSaving(false);
setErrors({});
setTimeout(() => titleInputRef.current?.focus(), 100);
}
}, [isOpen, reminder]);
useEffect(() => {
if (!isOpen) return;
setErrors(validate());
}, [isOpen, frequencyMinutes, startTime, endTime]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!title || !reminder || isSaving) return;
const validation = validate();
setErrors(validation);
if (Object.keys(validation).length > 0) {
return;
}
setIsSaving(true);
try {
await onUpdate({
@@ -51,7 +80,7 @@ const EditReminderModal: React.FC<EditReminderModalProps> = ({
endTime,
});
} catch (error) {
console.error('Failed to update reminder', error);
logger.ui.error('Failed to update reminder', error as Error);
alert('There was an error updating your reminder. Please try again.');
} finally {
setIsSaving(false);
@@ -126,12 +155,28 @@ const EditReminderModal: React.FC<EditReminderModalProps> = ({
type='number'
id='rem-edit-frequency'
value={frequencyMinutes}
onChange={e =>
setFrequencyMinutes(parseInt(e.target.value, 10))
}
min='1'
onChange={e => {
const value = parseInt(e.target.value, 10);
setFrequencyMinutes(Number.isNaN(value) ? 0 : value);
}}
min={MIN_REMINDER_FREQUENCY_MINUTES}
max={MAX_REMINDER_FREQUENCY_MINUTES}
aria-invalid={Boolean(errors.frequency)}
aria-describedby='rem-edit-frequency-help'
className='mt-1 block w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm bg-white dark:bg-slate-700 dark:border-slate-600'
/>
<p
id='rem-edit-frequency-help'
className={`mt-1 text-sm ${
errors.frequency
? 'text-red-600 dark:text-red-400'
: 'text-slate-500 dark:text-slate-400'
}`}
>
{errors.frequency
? errors.frequency
: `Minimum ${MIN_REMINDER_FREQUENCY_MINUTES} minutes, maximum ${MAX_REMINDER_FREQUENCY_MINUTES} minutes.`}
</p>
</div>
<div className='grid grid-cols-2 gap-4'>
@@ -148,6 +193,10 @@ const EditReminderModal: React.FC<EditReminderModalProps> = ({
value={startTime}
onChange={e => setStartTime(e.target.value)}
required
aria-invalid={Boolean(errors.timeRange)}
aria-describedby={
errors.timeRange ? 'rem-edit-time-help' : undefined
}
className='mt-1 block w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm bg-white dark:bg-slate-700 dark:border-slate-600'
/>
</div>
@@ -164,10 +213,22 @@ const EditReminderModal: React.FC<EditReminderModalProps> = ({
value={endTime}
onChange={e => setEndTime(e.target.value)}
required
aria-invalid={Boolean(errors.timeRange)}
aria-describedby={
errors.timeRange ? 'rem-edit-time-help' : undefined
}
className='mt-1 block w-full px-3 py-2 border border-slate-300 rounded-md shadow-sm focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm bg-white dark:bg-slate-700 dark:border-slate-600'
/>
</div>
</div>
{errors.timeRange && (
<p
id='rem-edit-time-help'
className='text-sm text-red-600 dark:text-red-400'
>
{errors.timeRange}
</p>
)}
</div>
<div className='px-6 py-4 bg-slate-50 dark:bg-slate-700/50 flex justify-end space-x-3 rounded-b-lg border-t border-slate-200 dark:border-slate-700'>
<button
@@ -180,7 +241,9 @@ const EditReminderModal: React.FC<EditReminderModalProps> = ({
</button>
<button
type='submit'
disabled={isSaving}
disabled={
isSaving || Boolean(errors.frequency || errors.timeRange)
}
className='px-4 py-2 text-sm font-medium text-white bg-indigo-600 border border-transparent rounded-md shadow-sm hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed flex items-center dark:focus:ring-offset-slate-800'
>
{isSaving ? 'Saving...' : 'Save Changes'}
@@ -0,0 +1,72 @@
import React from 'react';
import '@testing-library/jest-dom';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import AddReminderModal from '../AddReminderModal';
describe('AddReminderModal validation', () => {
const onClose = jest.fn();
const onAdd = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
});
it('displays an error when frequency is below the minimum', () => {
render(<AddReminderModal isOpen onClose={onClose} onAdd={onAdd} />);
const frequencyInput = screen.getByLabelText('Remind me every (minutes)');
fireEvent.change(frequencyInput, { target: { value: '1' } });
expect(
screen.getByText(/Choose a value between 5 and 720 minutes\./i)
).toBeInTheDocument();
const submitButton = screen.getByRole('button', { name: /add reminder/i });
expect(submitButton).toBeDisabled();
});
it('disables submit when end time is earlier than start time', () => {
render(<AddReminderModal isOpen onClose={onClose} onAdd={onAdd} />);
const startInput = screen.getByLabelText('From');
const endInput = screen.getByLabelText('Until');
fireEvent.change(startInput, { target: { value: '10:00' } });
fireEvent.change(endInput, { target: { value: '09:00' } });
expect(
screen.getByText(/End time must be later than start time\./i)
).toBeInTheDocument();
expect(
screen.getByRole('button', { name: /add reminder/i })
).toBeDisabled();
});
it('calls onAdd with valid data', async () => {
const resolveAdd = jest.fn().mockResolvedValue(undefined);
render(<AddReminderModal isOpen onClose={onClose} onAdd={resolveAdd} />);
fireEvent.change(screen.getByLabelText('Title'), {
target: { value: 'Hydrate' },
});
fireEvent.change(screen.getByLabelText('Remind me every (minutes)'), {
target: { value: '30' },
});
fireEvent.change(screen.getByLabelText('From'), {
target: { value: '08:00' },
});
fireEvent.change(screen.getByLabelText('Until'), {
target: { value: '12:00' },
});
fireEvent.click(screen.getByRole('button', { name: /add reminder/i }));
await waitFor(() => expect(resolveAdd).toHaveBeenCalledTimes(1));
expect(resolveAdd).toHaveBeenCalledWith({
title: 'Hydrate',
icon: 'bell',
frequencyMinutes: 30,
startTime: '08:00',
endTime: '12:00',
});
});
});
+54
View File
@@ -0,0 +1,54 @@
export const MIN_REMINDER_FREQUENCY_MINUTES = 5;
export const MAX_REMINDER_FREQUENCY_MINUTES = 720;
export const parseTimeToMinutes = (time: string): number | null => {
const [hours, minutes] = time.split(':').map(Number);
if (
Number.isNaN(hours) ||
Number.isNaN(minutes) ||
hours < 0 ||
hours > 23 ||
minutes < 0 ||
minutes > 59
) {
return null;
}
return hours * 60 + minutes;
};
export interface ReminderValidationParams {
frequencyMinutes: number;
startTime: string;
endTime: string;
}
export const validateReminderInputs = ({
frequencyMinutes,
startTime,
endTime,
}: ReminderValidationParams): { frequency?: string; timeRange?: string } => {
const errors: { frequency?: string; timeRange?: string } = {};
const frequency = Number(frequencyMinutes);
if (
!Number.isInteger(frequency) ||
frequency < MIN_REMINDER_FREQUENCY_MINUTES ||
frequency > MAX_REMINDER_FREQUENCY_MINUTES
) {
errors.frequency = `Choose a value between ${MIN_REMINDER_FREQUENCY_MINUTES} and ${MAX_REMINDER_FREQUENCY_MINUTES} minutes.`;
}
const startMinutes = parseTimeToMinutes(startTime);
const endMinutes = parseTimeToMinutes(endTime);
if (
startMinutes === null ||
endMinutes === null ||
endMinutes <= startMinutes
) {
errors.timeRange = 'End time must be later than start time.';
}
return errors;
};
+10 -2
View File
@@ -12,15 +12,23 @@ const ReminderCard: React.FC<ReminderCardProps> = ({ reminder }) => {
minute: '2-digit',
});
const ReminderIcon = getReminderIcon(reminder.icon);
const titleId = `reminder-${reminder.id}-title`;
return (
<li className='shadow-md rounded-lg p-4 flex flex-col justify-between transition-all duration-300 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700'>
<li
tabIndex={0}
aria-labelledby={titleId}
className='shadow-md rounded-lg p-4 flex flex-col justify-between transition-all duration-300 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-sky-400 dark:focus:ring-offset-slate-900'
>
<div>
<div className='flex justify-between items-start'>
<div className='flex items-center space-x-3'>
<ReminderIcon className='w-7 h-7 text-sky-500 dark:text-sky-400 flex-shrink-0' />
<div>
<h4 className='font-bold text-lg text-slate-800 dark:text-slate-100'>
<h4
id={titleId}
className='font-bold text-lg text-slate-800 dark:text-slate-100'
>
{reminder.title}
</h4>
</div>
+5 -3
View File
@@ -1,3 +1,5 @@
import { logger } from '../services/logging';
/**
* Unified Application Configuration System
*
@@ -754,10 +756,10 @@ function validateConfig(config: UnifiedConfig): void {
// Log warnings and throw errors
if (warnings.length > 0) {
console.warn('⚠️ Configuration warnings:', warnings);
logger.warn('Configuration warnings', 'CONFIG', warnings);
}
if (errors.length > 0) {
console.error('Configuration errors:', errors);
logger.error('Configuration errors', 'CONFIG', errors);
throw new Error(`Configuration validation failed: ${errors.join(', ')}`);
}
}
@@ -956,7 +958,7 @@ export function exportAsEnvVars(
*/
export function logConfig(): void {
if (unifiedConfig.features.debugMode) {
console.warn('🔧 Unified Configuration (Single Source of Truth):', {
logger.info('Unified Configuration (Single Source of Truth)', 'CONFIG', {
environment: unifiedConfig.app.environment,
app: unifiedConfig.app.name,
version: unifiedConfig.app.version,
+43 -25
View File
@@ -8,8 +8,12 @@ import React, {
import { User } from '../types';
import { databaseService } from '../services/database';
import { authService } from '../services/auth/auth.service';
import { tokenStorage } from '../utils/token';
import { logger } from '../services/logging';
import { normalizeError } from '../utils/error';
const SESSION_KEY = 'medication_app_session';
const AUTH_CONTEXT = 'USER_CONTEXT';
interface UserContextType {
user: User | null;
@@ -68,25 +72,23 @@ export const UserProvider: React.FC<{ children: ReactNode }> = ({
// Use auth service for password-based login
const result = await authService.login({ email, password });
console.warn('Login result received:', result);
console.warn('User from login:', result.user);
console.warn('User _id:', result.user._id);
// Update last login time
const updatedUser = { ...result.user, lastLoginAt: new Date() };
await databaseService.updateUser(updatedUser);
console.warn('Updated user with last login:', updatedUser);
// Store access token for subsequent API calls.
localStorage.setItem('access_token', result.accessToken);
tokenStorage.save({
accessToken: result.accessToken,
refreshToken: result.refreshToken,
});
// Set the user from the login result
setUser(updatedUser);
console.warn('User set in context');
logger.auth.login('User authenticated with email/password', {
userId: updatedUser._id,
email: updatedUser.email,
});
return true;
} catch (error) {
console.error('Login error:', error);
logger.auth.error('Login error', normalizeError(error), { email });
return false;
}
};
@@ -101,7 +103,10 @@ export const UserProvider: React.FC<{ children: ReactNode }> = ({
// Don't auto-login after registration, require email verification
return true;
} catch (error) {
console.error('Registration error:', error);
logger.auth.error('Registration error', normalizeError(error), {
email,
username,
});
return false;
}
};
@@ -113,23 +118,26 @@ export const UserProvider: React.FC<{ children: ReactNode }> = ({
try {
const result = await authService.loginWithOAuth(provider, userData);
console.warn('OAuth login result received:', result);
console.warn('OAuth user:', result.user);
console.warn('OAuth user _id:', result.user._id);
// Update last login time
const updatedUser = { ...result.user, lastLoginAt: new Date() };
await databaseService.updateUser(updatedUser);
console.warn('Updated OAuth user with last login:', updatedUser);
localStorage.setItem('access_token', result.accessToken);
tokenStorage.save({
accessToken: result.accessToken,
refreshToken: result.refreshToken,
});
setUser(updatedUser);
console.warn('OAuth user set in context');
logger.auth.login('User authenticated via OAuth', {
userId: updatedUser._id,
provider,
email: updatedUser.email,
});
return true;
} catch (error) {
console.error('OAuth login error:', error);
logger.auth.error('OAuth login error', normalizeError(error), {
provider,
email: userData.email,
});
return false;
}
};
@@ -144,15 +152,21 @@ export const UserProvider: React.FC<{ children: ReactNode }> = ({
}
await authService.changePassword(user._id, currentPassword, newPassword);
logger.auth.login('User changed password', { userId: user._id });
return true;
} catch (error) {
console.error('Password change error:', error);
logger.auth.error('Password change error', normalizeError(error), {
userId: user?._id,
});
return false;
}
};
const logout = () => {
const currentUserId = user?._id;
tokenStorage.clear();
setUser(null);
logger.auth.logout('User logged out', { userId: currentUserId });
};
const updateUser = async (updatedUser: User) => {
@@ -160,8 +174,12 @@ export const UserProvider: React.FC<{ children: ReactNode }> = ({
const savedUser = await databaseService.updateUser(updatedUser);
setUser(savedUser);
} catch (error) {
console.error('Failed to update user', error);
// Optionally revert state or show error
logger.error(
'Failed to update user profile',
AUTH_CONTEXT,
{ userId: updatedUser._id },
normalizeError(error)
);
}
};
+8
View File
@@ -0,0 +1,8 @@
[chttpd]
enable_cors = true
[cors]
origins = http://localhost:8080, http://localhost:5173
credentials = true
methods = GET, PUT, POST, HEAD, DELETE, OPTIONS
headers = accept, authorization, content-type, origin, referer, cache-control, x-requested-with
+16
View File
@@ -0,0 +1,16 @@
[admins]
admin = -pbkdf2:sha256-c9a393efac86b8a234ad91c5f7dd5a3d057ea7b76aad8b0194b41ff64ee80ec5,cab6f942a2c7d4ff7e5d54010475b7a2,600000
[couchdb]
uuid = 2083849204f5378942a1abfff8ef20cf
[chttpd_auth]
secret = 4e4abcc9cae38e179910098ad7f2f2e4
[chttpd]
bind_address = 0.0.0.0
port = 5984
[cluster]
n = 1
+40
View File
@@ -0,0 +1,40 @@
version: '3.8'
services:
couchdb:
image: couchdb:3
container_name: meds-couchdb
env_file:
- .env
environment:
COUCHDB_USER: ${COUCHDB_USER:-admin}
COUCHDB_PASSWORD: ${COUCHDB_PASSWORD:-change-this-secure-password}
ports:
- '5984:5984'
volumes:
- couchdb-data:/opt/couchdb/data
- ./couchdb-config:/opt/couchdb/etc/local.d
restart: unless-stopped
frontend:
build:
context: .
dockerfile: Dockerfile
args:
NODE_ENV: ${NODE_ENV:-production}
VITE_COUCHDB_URL: ${VITE_COUCHDB_URL:-http://localhost:5984}
VITE_COUCHDB_USER: ${VITE_COUCHDB_USER:-admin}
VITE_COUCHDB_PASSWORD: ${VITE_COUCHDB_PASSWORD:-change-this-secure-password}
VITE_ADMIN_EMAIL: ${VITE_ADMIN_EMAIL:-admin@localhost}
VITE_ADMIN_PASSWORD: ${VITE_ADMIN_PASSWORD:-admin123!}
env_file:
- .env
depends_on:
- couchdb
ports:
- '8080:80'
restart: unless-stopped
volumes:
couchdb-data:
name: meds-couchdb-data
+38
View File
@@ -10,6 +10,16 @@ The rxminder application supports multiple ways to configure deployments using e
2. **Dynamic Configuration**: Runtime environment variable injection
3. **Hybrid Approach**: Combination of both methods
### Run Profiles at a Glance
| Profile | Typical Usage | Key Files |
| ----------- | --------------------------------------------------------------- | ------------------------------------------- |
| Development | Vite dev server with hot reload and optional mock CouchDB | `.env.local`, `bun run dev` |
| Testing | Jest unit/integration suites against the mock database strategy | No additional env required (`bun run test`) |
| Production | Hardened build served by Docker/Kubernetes with real services | `.env`, Docker/compose manifests |
See the [Run Profiles section](../../README.md#-run-profiles) in the project README for commands and best practices.
## Environment Variable Sources
Variables are loaded in the following priority order (last wins):
@@ -193,6 +203,18 @@ DEV_API_URL=http://localhost:5984
| `COUCHDB_USERNAME` | `admin` | Database username |
| `COUCHDB_PASSWORD` | - | Database password (required) |
### Email (Mailgun) Variables
| Variable | Default | Description |
| ------------------------- | ---------------------------- | ------------------------------------------------------------------------------ |
| `VITE_MAILGUN_API_KEY` | _required_ | Mailgun API key used for authenticated requests |
| `VITE_MAILGUN_DOMAIN` | _required_ | Mailgun sending domain (e.g. `mg.yourdomain.com`) |
| `VITE_MAILGUN_BASE_URL` | `https://api.mailgun.net/v3` | Mailgun REST API base URL |
| `VITE_MAILGUN_FROM_NAME` | `Medication Reminder` | Friendly name used in the `from` header |
| `VITE_MAILGUN_FROM_EMAIL` | _required_ | Email address used in the `from` header (must belong to the configured domain) |
> **Tip:** When any required Mailgun variables are missing, the application falls back to a development mode that logs email previews instead of sending real messages. Configure the variables above in `.env.local` (git ignored) before testing real email flows.
### Network & Ingress Variables
| Variable | Default | Description |
@@ -203,6 +225,8 @@ DEV_API_URL=http://localhost:5984
| `CERT_MANAGER_ISSUER` | `letsencrypt-prod` | Certificate issuer |
| `CORS_ORIGIN` | `*` | CORS allowed origins |
> When running via `docker compose up --build`, CouchDB CORS settings are sourced from `couchdb-config/cors.ini`. Update the `origins` list in that file to add additional frontend domains.
### Performance Variables
| Variable | Default | Description |
@@ -243,6 +267,20 @@ DEV_API_URL=http://localhost:5984
| `API_SECRET_KEY` | - | API secret key |
| `JWT_SECRET` | - | JWT signing secret |
### Bootstrap Admin Variables
These variables control the default admin account created/updated at app startup by the frontend seeder. They are read at build-time (Vite), so changing them requires rebuilding the frontend image.
| Variable | Default | Description |
| --------------------- | ----------------- | ----------------------------------- |
| `VITE_ADMIN_EMAIL` | `admin@localhost` | Email of the default admin user |
| `VITE_ADMIN_PASSWORD` | `admin123!` | Password for the default admin user |
Notes:
- To change these in Docker, set build args in `docker-compose.yaml` or define them in `.env` and rebuild: `docker compose build frontend && docker compose up -d`.
- The seeder is idempotent: if a user with this email exists, it updates role/status and keeps the latest password you set.
## Usage Examples
### Basic Development Setup
+1
View File
@@ -18,6 +18,7 @@
"test:unit": "jest --testPathPatterns='(utils|types).*test\\.(ts|js)$'",
"test:services": "jest --testPathPatterns='services.*test\\.(ts|js)$'",
"test:integration": "jest --testPathPatterns='(tests/integration/.*\\.test\\.(ts|js)|services/.*/__tests__/integration/.*\\.test\\.(ts|js))$'",
"seed": "bun run scripts/seed.ts",
"lint:markdown": "markdownlint-cli2 \"**/*.md\"",
"lint:markdown:fix": "markdownlint-cli2 --fix \"**/*.md\"",
"check:secrets": "secretlint \"**/*\"",
+20
View File
@@ -0,0 +1,20 @@
import { databaseSeeder } from '../services/database.seeder';
import { logger } from '../services/logging';
const run = async () => {
try {
await databaseSeeder.seedDatabase();
logger.info('Database seeding complete', 'SEEDER');
process.exit(0);
} catch (error) {
logger.error(
'Database seeding failed',
'SEEDER',
undefined,
error as Error
);
process.exit(1);
}
};
run();
+139 -25
View File
@@ -1,15 +1,17 @@
// Mock the mailgun config before any imports
const mockGetMailgunConfig = jest.fn().mockReturnValue({
apiKey: 'test-api-key',
domain: 'test.mailgun.org',
baseUrl: 'https://api.mailgun.net/v3',
fromName: 'Test App',
fromEmail: 'test@example.com',
});
jest.mock('../mailgun.config', () => {
const defaultConfig = {
apiKey: 'test-api-key',
domain: 'test.mailgun.org',
baseUrl: 'https://api.mailgun.net/v3',
fromName: 'Test App',
fromEmail: 'test@example.com',
};
jest.mock('../mailgun.config', () => ({
getMailgunConfig: mockGetMailgunConfig,
}));
return {
getMailgunConfig: jest.fn(() => defaultConfig),
};
});
// Mock the app config
jest.mock('../../config/unified.config', () => ({
@@ -18,6 +20,7 @@ jest.mock('../../config/unified.config', () => ({
baseUrl: 'http://localhost:3000',
},
},
getAppConfig: jest.fn(() => ({ baseUrl: 'http://localhost:3000' })),
}));
// Mock global fetch and related APIs
@@ -32,10 +35,28 @@ global.btoa = jest
// Import the service after mocks are set up
import { MailgunService } from '../mailgun.service';
import { getMailgunConfig } from '../mailgun.config';
import { logger } from '../logging';
const mockGetMailgunConfig = getMailgunConfig as jest.MockedFunction<
typeof getMailgunConfig
>;
mockGetMailgunConfig.mockReturnValue({
apiKey: 'test-api-key',
domain: 'test.mailgun.org',
baseUrl: 'https://api.mailgun.net/v3',
fromName: 'Test App',
fromEmail: 'test@example.com',
});
describe('MailgunService', () => {
let mockFetch: jest.MockedFunction<typeof fetch>;
let mockFormData: jest.MockedFunction<any>;
let warnSpy: jest.SpyInstance;
let infoSpy: jest.SpyInstance;
let errorSpy: jest.SpyInstance;
let debugSpy: jest.SpyInstance;
const mockConfig = {
apiKey: 'test-api-key',
@@ -47,10 +68,13 @@ describe('MailgunService', () => {
beforeEach(() => {
jest.clearAllMocks();
console.warn = jest.fn();
console.error = jest.fn();
mockFetch = fetch as jest.MockedFunction<typeof fetch>;
mockFormData = MockFormData;
warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => undefined);
infoSpy = jest.spyOn(logger, 'info').mockImplementation(() => undefined);
errorSpy = jest.spyOn(logger, 'error').mockImplementation(() => undefined);
debugSpy = jest.spyOn(logger, 'debug').mockImplementation(() => undefined);
});
describe('constructor', () => {
@@ -67,11 +91,28 @@ describe('MailgunService', () => {
new MailgunService();
expect(console.warn).toHaveBeenCalledWith(
'📧 Mailgun Service: Running in development mode (emails will be logged only)'
expect(warnSpy).toHaveBeenCalledWith(
'Mailgun running in development mode; emails will not be delivered',
'MAILGUN',
{
missingFields: [
'VITE_MAILGUN_API_KEY',
'VITE_MAILGUN_DOMAIN',
'VITE_MAILGUN_FROM_EMAIL',
],
domain: undefined,
}
);
expect(console.warn).toHaveBeenCalledWith(
'💡 To enable real emails, configure Mailgun credentials in .env.local'
expect(infoSpy).toHaveBeenCalledWith(
'To enable email delivery, configure Mailgun environment variables',
'MAILGUN',
{
requiredVariables: [
'VITE_MAILGUN_API_KEY',
'VITE_MAILGUN_DOMAIN',
'VITE_MAILGUN_FROM_EMAIL',
],
}
);
});
@@ -80,10 +121,15 @@ describe('MailgunService', () => {
new MailgunService();
expect(console.warn).toHaveBeenCalledWith(
'📧 Mailgun Service: Configured for production with domain:',
'test.mailgun.org'
expect(infoSpy).toHaveBeenCalledWith(
'Mailgun configured for delivery',
'MAILGUN',
{
domain: 'test.mailgun.org',
fromEmail: 'test@example.com',
}
);
expect(warnSpy).not.toHaveBeenCalled();
});
});
@@ -122,8 +168,9 @@ describe('MailgunService', () => {
}),
})
);
expect(console.warn).toHaveBeenCalledWith(
'📧 Email sent successfully via Mailgun:',
expect(infoSpy).toHaveBeenCalledWith(
'Email sent via Mailgun',
'MAILGUN',
{
to: 'test@example.com',
subject: 'Test Subject',
@@ -148,8 +195,10 @@ describe('MailgunService', () => {
const result = await service.sendEmail('test@example.com', template);
expect(result).toBe(false);
expect(console.error).toHaveBeenCalledWith(
'Email sending failed:',
expect(errorSpy).toHaveBeenCalledWith(
'Mailgun email send failed',
'MAILGUN',
{ domain: 'test.mailgun.org' },
expect.any(Error)
);
});
@@ -165,8 +214,10 @@ describe('MailgunService', () => {
const result = await service.sendEmail('test@example.com', template);
expect(result).toBe(false);
expect(console.error).toHaveBeenCalledWith(
'Email sending failed:',
expect(errorSpy).toHaveBeenCalledWith(
'Mailgun email send failed',
'MAILGUN',
{ domain: 'test.mailgun.org' },
expect.any(Error)
);
});
@@ -237,6 +288,56 @@ describe('MailgunService', () => {
expect.anything()
);
});
test('logs preview and skips send when configuration is missing', async () => {
const unconfiguredConfig = {
apiKey: undefined,
domain: undefined,
baseUrl: 'https://api.mailgun.net/v3',
fromName: 'Test App',
fromEmail: undefined,
};
mockGetMailgunConfig.mockReturnValue(unconfiguredConfig);
const unconfiguredService = new MailgunService();
const template = {
subject: 'Test Subject',
html: '<p>Test HTML</p>',
};
const result = await unconfiguredService.sendEmail(
'test@example.com',
template
);
expect(result).toBe(false);
expect(mockFetch).not.toHaveBeenCalled();
expect(warnSpy).toHaveBeenCalledWith(
'Skipping email send; Mailgun is not configured',
'MAILGUN',
expect.objectContaining({
to: 'test@example.com',
missingFields: [
'VITE_MAILGUN_API_KEY',
'VITE_MAILGUN_DOMAIN',
'VITE_MAILGUN_FROM_EMAIL',
],
preview: true,
})
);
expect(debugSpy).toHaveBeenCalledWith(
'Mailgun email preview',
'MAILGUN',
expect.objectContaining({
to: 'test@example.com',
subject: 'Test Subject',
html: '<p>Test HTML</p>',
})
);
mockGetMailgunConfig.mockReturnValue(mockConfig);
});
});
describe('sendVerificationEmail', () => {
@@ -360,6 +461,7 @@ describe('MailgunService', () => {
mode: 'production',
domain: 'test.mailgun.org',
fromEmail: 'test@example.com',
missingFields: [],
});
});
@@ -382,6 +484,11 @@ describe('MailgunService', () => {
mode: 'development',
domain: undefined,
fromEmail: undefined,
missingFields: [
'VITE_MAILGUN_API_KEY',
'VITE_MAILGUN_DOMAIN',
'VITE_MAILGUN_FROM_EMAIL',
],
});
});
@@ -404,6 +511,13 @@ describe('MailgunService', () => {
mode: 'development',
domain: '',
fromEmail: '',
missingFields: [
'VITE_MAILGUN_API_KEY',
'VITE_MAILGUN_DOMAIN',
'VITE_MAILGUN_FROM_EMAIL',
'VITE_MAILGUN_BASE_URL',
'VITE_MAILGUN_FROM_NAME',
],
});
});
});
@@ -0,0 +1,365 @@
import type { EmailVerificationToken } from '../auth.types';
import type { PasswordResetToken } from '../token.service';
import { logger } from '../../logging';
jest.mock('../../../config/unified.config', () => {
const actual = jest.requireActual('../../../config/unified.config');
return {
...actual,
getDatabaseConfig: jest.fn(),
};
});
const { getDatabaseConfig } = jest.requireMock(
'../../../config/unified.config'
) as {
getDatabaseConfig: jest.MockedFunction<
() => {
url: string;
username: string;
password: string;
useMock?: boolean;
}
>;
};
getDatabaseConfig.mockReturnValue({
url: '',
username: '',
password: '',
useMock: true,
});
let TokenServiceClass: typeof import('../token.service').TokenService;
beforeAll(async () => {
({ TokenService: TokenServiceClass } = await import('../token.service'));
});
describe('TokenService (localStorage fallback)', () => {
let service: TokenService;
beforeEach(() => {
getDatabaseConfig.mockReturnValue({
url: '',
username: '',
password: '',
useMock: true,
});
service = new TokenServiceClass();
localStorage.clear();
});
it('stores and retrieves verification tokens with Date instances', async () => {
const expiresAt = new Date('2025-01-01T00:00:00.000Z');
const token: EmailVerificationToken = {
userId: 'user-1',
email: 'user@example.com',
token: 'verify-123',
expiresAt,
};
await service.saveVerificationToken(token);
const stored = await service.findVerificationToken('verify-123');
expect(stored).not.toBeNull();
expect(stored?.expiresAt).toBeInstanceOf(Date);
expect(stored?.expiresAt.getTime()).toBe(expiresAt.getTime());
});
it('removes verification tokens only for the targeted user', async () => {
const expiry = new Date(Date.now() + 60_000);
await service.saveVerificationToken({
userId: 'user-a',
email: 'a@example.com',
token: 'token-a',
expiresAt: expiry,
});
await service.saveVerificationToken({
userId: 'user-b',
email: 'b@example.com',
token: 'token-b',
expiresAt: expiry,
});
await service.deleteVerificationTokensForUser('user-a');
expect(await service.findVerificationToken('token-a')).toBeNull();
expect(await service.findVerificationToken('token-b')).not.toBeNull();
});
it('supports password reset token lifecycle (save, find, delete)', async () => {
const expiresAt = new Date(Date.now() + 120_000);
const token: PasswordResetToken = {
userId: 'reset-user',
email: 'reset@example.com',
token: 'reset-123',
expiresAt,
};
await service.savePasswordResetToken(token);
const stored = await service.findPasswordResetToken('reset-123');
expect(stored).not.toBeNull();
expect(stored?.expiresAt).toBeInstanceOf(Date);
await service.deletePasswordResetToken('reset-123');
expect(await service.findPasswordResetToken('reset-123')).toBeNull();
});
it('removes password reset tokens by user id', async () => {
const expiresAt = new Date(Date.now() + 120_000);
await service.savePasswordResetToken({
userId: 'reset-a',
email: 'a@example.com',
token: 'reset-a-token',
expiresAt,
});
await service.savePasswordResetToken({
userId: 'reset-b',
email: 'b@example.com',
token: 'reset-b-token',
expiresAt,
});
await service.deletePasswordResetTokensForUser('reset-a');
expect(await service.findPasswordResetToken('reset-a-token')).toBeNull();
expect(
await service.findPasswordResetToken('reset-b-token')
).not.toBeNull();
});
it('cleans up expired tokens from both verification and reset stores', async () => {
const past = new Date(Date.now() - 60_000);
const future = new Date(Date.now() + 60_000);
await service.saveVerificationToken({
userId: 'expired',
email: 'expired@example.com',
token: 'expired-ver',
expiresAt: past,
});
await service.saveVerificationToken({
userId: 'valid',
email: 'valid@example.com',
token: 'valid-ver',
expiresAt: future,
});
await service.savePasswordResetToken({
userId: 'expired',
email: 'expired@example.com',
token: 'expired-reset',
expiresAt: past,
});
await service.savePasswordResetToken({
userId: 'valid',
email: 'valid@example.com',
token: 'valid-reset',
expiresAt: future,
});
const removed = await service.cleanupExpiredTokens();
expect(removed).toBe(2);
expect(await service.findVerificationToken('expired-ver')).toBeNull();
expect(await service.findVerificationToken('valid-ver')).not.toBeNull();
expect(await service.findPasswordResetToken('expired-reset')).toBeNull();
expect(await service.findPasswordResetToken('valid-reset')).not.toBeNull();
});
});
describe('TokenService (CouchDB mode)', () => {
let service: TokenService;
let docs: Record<string, any>;
let revCounter: number;
beforeEach(() => {
getDatabaseConfig.mockReturnValue({
url: 'http://couch.local',
username: 'admin',
password: 'secret',
useMock: false,
});
service = new TokenServiceClass();
docs = {};
revCounter = 1;
jest.spyOn(service as any, 'ensureDatabase').mockResolvedValue(undefined);
(service as any).usingCouch = true;
(service as any).initialized = true;
(service as any).couchBaseUrl = 'http://couch.local';
(service as any).couchAuthHeader = 'Basic test';
jest
.spyOn(service as any, 'makeCouchRequest')
.mockImplementation(async (method: string, path: string, body?: any) => {
const [resourcePath] = path.split('?');
const segments = resourcePath.split('/');
const docId = segments[2];
if (method === 'GET' && resourcePath === '/auth_tokens/_all_docs') {
return {
rows: Object.values(docs).map((doc: any) => ({
id: doc._id,
value: {},
doc,
})),
};
}
if (method === 'POST' && resourcePath === '/auth_tokens/_find') {
const selector = body.selector || {};
const matching = Object.values(docs).filter((doc: any) => {
return Object.entries(selector).every(
([key, value]) => doc[key] === value
);
});
return { docs: matching };
}
if (method === 'GET') {
const doc = docs[docId];
if (!doc) {
throw new Error('not found');
}
return doc;
}
if (method === 'PUT') {
const newRev = `rev-${revCounter++}`;
const doc = { ...body, _rev: newRev };
docs[doc._id] = doc;
return { id: doc._id, rev: newRev };
}
if (method === 'DELETE') {
if (!docs[docId]) {
throw new Error('missing');
}
delete docs[docId];
return { ok: true };
}
throw new Error(`Unhandled request: ${method} ${path}`);
});
jest.spyOn(logger.db, 'error').mockImplementation(() => undefined);
jest.spyOn(logger.db, 'warn').mockImplementation(() => undefined);
});
afterEach(() => {
jest.restoreAllMocks();
});
it('persists and retrieves verification tokens via Couch paths', async () => {
const expiresAt = new Date('2025-01-02T00:00:00.000Z');
await service.saveVerificationToken({
userId: 'user-1',
email: 'user@example.com',
token: 'ver-1',
expiresAt,
});
const found = await service.findVerificationToken('ver-1');
expect(found).not.toBeNull();
expect(found?.expiresAt).toBeInstanceOf(Date);
expect(found?.expiresAt.getTime()).toBe(expiresAt.getTime());
expect(Object.keys(docs)).toContain('ver-ver-1');
});
it('deletes verification tokens for a user using Mango query results', async () => {
const future = new Date(Date.now() + 60_000).toISOString();
docs['ver-keep'] = {
_id: 'ver-keep',
_rev: 'rev-1',
tokenType: 'verification',
token: 'keep',
userId: 'user-keep',
email: 'keep@example.com',
expiresAt: future,
createdAt: future,
};
docs['ver-drop'] = {
_id: 'ver-drop',
_rev: 'rev-2',
tokenType: 'verification',
token: 'drop',
userId: 'user-drop',
email: 'drop@example.com',
expiresAt: future,
createdAt: future,
};
await service.deleteVerificationTokensForUser('user-drop');
expect(docs['ver-drop']).toBeUndefined();
expect(docs['ver-keep']).toBeDefined();
});
it('handles password reset token lifecycle via Couch calls', async () => {
const expiresAt = new Date('2025-01-03T00:00:00.000Z');
await service.savePasswordResetToken({
userId: 'reset-user',
email: 'reset@example.com',
token: 'rst-1',
expiresAt,
});
const found = await service.findPasswordResetToken('rst-1');
expect(found).not.toBeNull();
expect(found?.expiresAt.getTime()).toBe(expiresAt.getTime());
await service.deletePasswordResetToken('rst-1');
expect(docs['rst-rst-1']).toBeUndefined();
});
it('cleans up expired Couch documents and keeps valid ones', async () => {
const past = new Date(Date.now() - 60_000).toISOString();
const future = new Date(Date.now() + 60_000).toISOString();
docs['ver-expired'] = {
_id: 'ver-expired',
_rev: 'rev-5',
tokenType: 'verification',
token: 'expired',
userId: 'user-expired',
email: 'expired@example.com',
expiresAt: past,
createdAt: past,
};
docs['rst-expired'] = {
_id: 'rst-expired',
_rev: 'rev-6',
tokenType: 'reset',
token: 'rst-expired',
userId: 'user-expired',
email: 'expired@example.com',
expiresAt: past,
createdAt: past,
};
docs['ver-valid'] = {
_id: 'ver-valid',
_rev: 'rev-7',
tokenType: 'verification',
token: 'valid',
userId: 'user-valid',
email: 'valid@example.com',
expiresAt: future,
createdAt: future,
};
const removed = await service.cleanupExpiredTokens();
expect(removed).toBe(2);
expect(docs['ver-expired']).toBeUndefined();
expect(docs['rst-expired']).toBeUndefined();
expect(docs['ver-valid']).toBeDefined();
});
});
+2 -1
View File
@@ -4,6 +4,7 @@ import { mailgunService } from '../mailgun.service';
import { AccountStatus } from './auth.constants';
import { databaseService } from '../database';
import { tokenService } from './token.service';
import { logger } from '../logging';
const TOKEN_EXPIRY_HOURS = 24;
@@ -32,7 +33,7 @@ export class EmailVerificationService {
token
);
if (!emailSent) {
console.warn('Failed to send verification email');
logger.auth.warn('Failed to send verification email');
}
}
+3 -8
View File
@@ -9,6 +9,7 @@ import { EmailVerificationToken } from './auth.types';
import type { CouchDBDocument } from '../../types';
import { getDatabaseConfig } from '../../config/unified.config';
import { logger } from '../logging';
import { encodeBase64 } from '../../utils/base64';
export interface PasswordResetToken {
userId: string;
@@ -42,14 +43,8 @@ function fromISO(date: string | Date): Date {
return date instanceof Date ? date : new Date(date);
}
function base64Auth(user: string, pass: string): string {
// btoa may not exist in some environments (e.g., Node). Fallback to Buffer.
if (typeof btoa !== 'undefined') {
return btoa(`${user}:${pass}`);
}
return Buffer.from(`${user}:${pass}`).toString('base64');
}
const base64Auth = (user: string, pass: string): string =>
encodeBase64(`${user}:${pass}`);
export class TokenService {
private couchBaseUrl: string | null = null;
+34 -22
View File
@@ -1,32 +1,36 @@
import { databaseService } from './database';
import { AccountStatus } from './auth/auth.constants';
import { UserRole } from '../types';
import { hashPassword, isBcryptHash } from './auth/password.service';
import { logger } from './logging';
export class DatabaseSeeder {
private static seedingInProgress = false;
private static seedingCompleted = false;
async seedDefaultAdmin(): Promise<void> {
const adminEmail = 'admin@localhost';
const adminPassword = 'admin123!';
const adminEmail =
(import.meta as any)?.env?.VITE_ADMIN_EMAIL || 'admin@localhost';
const adminPassword =
(import.meta as any)?.env?.VITE_ADMIN_PASSWORD || 'admin123!';
console.warn('🌱 Starting admin user seeding...');
console.warn('📧 Admin email:', adminEmail);
logger.db.info('🌱 Starting admin user seeding...');
logger.db.info('📧 Admin email:', adminEmail);
try {
// Check if admin already exists
const existingAdmin = await databaseService.findUserByEmail(adminEmail);
if (existingAdmin) {
console.warn('✅ Default admin user already exists');
console.warn('👤 Existing admin:', existingAdmin);
logger.db.info('✅ Default admin user already exists');
logger.db.info('👤 Existing admin:', existingAdmin);
// Check if admin needs to be updated to correct role/status
if (
existingAdmin.role !== UserRole.ADMIN ||
existingAdmin.status !== AccountStatus.ACTIVE
) {
console.warn('🔧 Updating admin user role and status...');
logger.db.info('🔧 Updating admin user role and status...');
const updatedAdmin = {
...existingAdmin,
role: UserRole.ADMIN,
@@ -34,21 +38,25 @@ export class DatabaseSeeder {
emailVerified: true,
};
await databaseService.updateUser(updatedAdmin);
console.warn('✅ Admin user updated successfully');
console.warn('👤 Updated admin:', updatedAdmin);
logger.db.info('✅ Admin user updated successfully');
logger.db.info('👤 Updated admin:', updatedAdmin);
}
return;
}
console.warn('🚀 Creating new admin user...');
logger.db.info('🚀 Creating new admin user...');
// Create default admin user
const passwordToUse = isBcryptHash(adminPassword)
? adminPassword
: await hashPassword(adminPassword);
const adminUser = await databaseService.createUserWithPassword(
adminEmail,
adminPassword,
passwordToUse,
'admin'
);
console.warn('👤 Admin user created:', adminUser);
logger.db.info('👤 Admin user created:', adminUser);
// Update user to admin role and active status
const updatedAdmin = {
@@ -62,13 +70,15 @@ export class DatabaseSeeder {
await databaseService.updateUser(updatedAdmin);
console.warn('✅ Admin user created successfully');
console.warn('👤 Final admin user:', updatedAdmin);
console.warn('📧 Email:', adminEmail);
console.warn('🔑 Password:', adminPassword);
console.warn('⚠️ Please change the default password after first login!');
logger.db.info('✅ Admin user created successfully');
logger.db.info('👤 Final admin user:', updatedAdmin);
logger.db.info('📧 Email:', adminEmail);
logger.db.info('🔑 Password:', adminPassword);
logger.db.info(
'⚠️ Please change the default password after first login!'
);
} catch (error) {
console.error('❌ Failed to create default admin user:', error);
logger.db.error('❌ Failed to create default admin user:', error);
throw error;
}
}
@@ -76,19 +86,21 @@ export class DatabaseSeeder {
async seedDatabase(): Promise<void> {
// Prevent multiple seeding attempts
if (DatabaseSeeder.seedingInProgress || DatabaseSeeder.seedingCompleted) {
console.warn('🔄 Seeding already in progress or completed, skipping...');
logger.db.info(
'🔄 Seeding already in progress or completed, skipping...'
);
return;
}
DatabaseSeeder.seedingInProgress = true;
console.warn('🌱 Starting database seeding...');
logger.db.info('🌱 Starting database seeding...');
try {
await this.seedDefaultAdmin();
DatabaseSeeder.seedingCompleted = true;
console.warn('🎯 Admin seeding completed successfully');
logger.db.info('🎯 Admin seeding completed successfully');
} catch (error) {
console.error('💥 Database seeding failed:', error);
logger.db.error('💥 Database seeding failed:', error);
throw error;
} finally {
DatabaseSeeder.seedingInProgress = false;
+7 -4
View File
@@ -3,6 +3,8 @@ import { MockDatabaseStrategy } from './MockDatabaseStrategy';
import { ProductionDatabaseStrategy } from './ProductionDatabaseStrategy';
import { DatabaseStrategy } from './types';
import { AccountStatus } from '../auth/auth.constants';
import { hashPassword } from '../auth/password.service';
import { logger } from '../logging';
/**
* Consolidated Database Service
@@ -30,9 +32,9 @@ export class DatabaseService implements DatabaseStrategy {
try {
return new ProductionDatabaseStrategy();
} catch (error) {
console.warn(
'Production CouchDB service not available, falling back to mock:',
error
logger.db.warn(
'Production CouchDB service not available, falling back to mock',
error as Error
);
return new MockDatabaseStrategy();
}
@@ -188,9 +190,10 @@ export class DatabaseService implements DatabaseStrategy {
async changeUserPassword(userId: string, newPassword: string) {
const user = await this.strategy.getUserById(userId);
if (!user) throw new Error('User not found');
const hashedPassword = await hashPassword(newPassword);
return this.strategy.updateUser({
...user,
password: newPassword,
password: hashedPassword,
});
}
+80 -13
View File
@@ -12,6 +12,7 @@ import { AccountStatus } from '../auth/auth.constants';
import { DatabaseStrategy, DatabaseError } from './types';
import { getDatabaseConfig } from '../../config/unified.config';
import { logger } from '../logging';
import { encodeBase64 } from '../../utils/base64';
export class ProductionDatabaseStrategy implements DatabaseStrategy {
private baseUrl: string;
@@ -22,7 +23,7 @@ export class ProductionDatabaseStrategy implements DatabaseStrategy {
const dbConfig = getDatabaseConfig();
this.baseUrl = dbConfig.url;
this.auth = btoa(`${dbConfig.username}:${dbConfig.password}`);
this.auth = encodeBase64(`${dbConfig.username}:${dbConfig.password}`);
logger.db.query('Initializing production database strategy', {
url: dbConfig.url,
@@ -47,6 +48,9 @@ export class ProductionDatabaseStrategy implements DatabaseStrategy {
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}`,
@@ -56,6 +60,40 @@ export class ProductionDatabaseStrategy implements DatabaseStrategy {
}
}
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
@@ -150,18 +188,46 @@ export class ProductionDatabaseStrategy implements DatabaseStrategy {
private async putDoc<T extends CouchDBDocument>(
dbName: string,
doc: T
doc: T,
retries = 2
): Promise<T> {
const response = await this.makeRequest<{ id: string; rev: string }>(
'PUT',
`/${dbName}/${doc._id}`,
doc
);
try {
const response = await this.makeRequest<{ id: string; rev: string }>(
'PUT',
`/${dbName}/${doc._id}`,
doc
);
return {
...doc,
_rev: response.rev,
};
return {
...doc,
_rev: response.rev,
};
} catch (error) {
if (
error instanceof DatabaseError &&
error.status === 409 &&
retries > 0 &&
doc._rev
) {
logger.db.warn('Document conflict detected, retrying update', {
dbName,
id: doc._id,
retriesRemaining: retries,
});
const latest = await this.getDoc<T>(dbName, doc._id);
if (latest) {
const mergedDoc = {
...latest,
...doc,
_rev: latest._rev,
};
return this.putDoc(dbName, mergedDoc, retries - 1);
}
}
throw error;
}
}
private async deleteDoc(
@@ -225,13 +291,14 @@ export class ProductionDatabaseStrategy implements DatabaseStrategy {
async findUserByEmail(email: string): Promise<User | null> {
const response = await this.makeRequest<{
rows: Array<{ doc: User }>;
docs: User[];
warning?: string;
}>('POST', '/users/_find', {
selector: { email },
limit: 1,
});
return response.rows[0]?.doc || null;
return response.docs[0] || null;
}
async deleteUser(id: string): Promise<boolean> {
+6 -3
View File
@@ -1,6 +1,8 @@
/**
* Mock email service for sending verification emails
*/
import { logger } from './logging';
export class EmailService {
/**
* Simulates sending a verification email with a link to /verify-email?token=${token}
@@ -10,10 +12,11 @@ export class EmailService {
async sendVerificationEmail(email: string, token: string): Promise<void> {
// In a real implementation, this would send an actual email
// For this demo, we'll just log the action
console.warn(
`📧 Sending verification email to ${email} with token: ${token}`
logger.info(
`Sending verification email to ${email} with token: ${token}`,
'EMAIL'
);
console.warn(`🔗 Verification link: /verify-email?token=${token}`);
logger.info(`Verification link: /verify-email?token=${token}`, 'EMAIL');
// Simulate network delay
await new Promise(resolve => setTimeout(resolve, 500));
+83 -18
View File
@@ -5,6 +5,9 @@
import { getMailgunConfig, type MailgunConfig } from './mailgun.config';
import { getAppConfig } from '../config/unified.config';
import { logger } from './logging';
import { normalizeError } from '../utils/error';
import { encodeBase64 } from '../utils/base64';
interface EmailTemplate {
subject: string;
@@ -14,24 +17,34 @@ interface EmailTemplate {
export class MailgunService {
private config: MailgunConfig;
private readonly context = 'MAILGUN';
constructor() {
this.config = getMailgunConfig();
// Log configuration status on startup
const status = this.getConfigurationStatus();
if (status.mode === 'development') {
console.warn(
'📧 Mailgun Service: Running in development mode (emails will be logged only)'
if (!status.configured) {
logger.warn(
'Mailgun running in development mode; emails will not be delivered',
this.context,
{
missingFields: status.missingFields,
domain: status.domain,
}
);
console.warn(
'💡 To enable real emails, configure Mailgun credentials in .env.local'
logger.info(
'To enable email delivery, configure Mailgun environment variables',
this.context,
{
requiredVariables: status.missingFields,
}
);
} else {
console.warn(
'📧 Mailgun Service: Configured for production with domain:',
status.domain
);
logger.info('Mailgun configured for delivery', this.context, {
domain: status.domain,
fromEmail: status.fromEmail,
});
}
}
@@ -95,6 +108,27 @@ export class MailgunService {
async sendEmail(to: string, template: EmailTemplate): Promise<boolean> {
try {
const status = this.getConfigurationStatus();
if (!status.configured) {
logger.warn(
'Skipping email send; Mailgun is not configured',
this.context,
{
to,
subject: template.subject,
missingFields: status.missingFields,
preview: status.mode === 'development',
}
);
logger.debug('Mailgun email preview', this.context, {
to,
subject: template.subject,
html: template.html,
text: template.text,
});
return false;
}
// Production Mailgun API call
const formData = new FormData();
formData.append(
@@ -113,7 +147,7 @@ export class MailgunService {
{
method: 'POST',
headers: {
Authorization: `Basic ${btoa(`api:${this.config.apiKey}`)}`,
Authorization: `Basic ${encodeBase64(`api:${this.config.apiKey}`)}`,
},
body: formData,
}
@@ -125,7 +159,7 @@ export class MailgunService {
}
const result = await response.json();
console.warn('📧 Email sent successfully via Mailgun:', {
logger.info('Email sent via Mailgun', this.context, {
to,
subject: template.subject,
messageId: result.id,
@@ -133,7 +167,14 @@ export class MailgunService {
return true;
} catch (error: unknown) {
console.error('Email sending failed:', error);
logger.error(
'Mailgun email send failed',
this.context,
{
domain: this.config.domain,
},
normalizeError(error)
);
return false;
}
}
@@ -156,13 +197,10 @@ export class MailgunService {
mode: 'development' | 'production';
domain: string;
fromEmail: string;
missingFields: string[];
} {
const configured =
!!this.config.apiKey &&
!!this.config.domain &&
!!this.config.baseUrl &&
!!this.config.fromEmail &&
!!this.config.fromName;
const missingFields = this.getMissingFields();
const configured = missingFields.length === 0;
const mode: 'development' | 'production' = configured
? 'production'
: 'development';
@@ -171,8 +209,35 @@ export class MailgunService {
mode,
domain: this.config.domain,
fromEmail: this.config.fromEmail,
missingFields,
};
}
private getMissingFields(): string[] {
const missing: string[] = [];
if (!this.config.apiKey) {
missing.push('VITE_MAILGUN_API_KEY');
}
if (!this.config.domain) {
missing.push('VITE_MAILGUN_DOMAIN');
}
if (!this.config.fromEmail) {
missing.push('VITE_MAILGUN_FROM_EMAIL');
}
if (!this.config.baseUrl) {
missing.push('VITE_MAILGUN_BASE_URL');
}
if (!this.config.fromName) {
missing.push('VITE_MAILGUN_FROM_NAME');
}
return missing;
}
}
export const mailgunService = new MailgunService();
+63
View File
@@ -0,0 +1,63 @@
import { determineDoseStatus } from '../doseStatus';
import { DoseStatus } from '../../types';
describe('determineDoseStatus', () => {
const scheduledTime = new Date('2024-05-10T08:00:00.000Z');
const now = new Date('2024-05-10T07:00:00.000Z');
it('returns TAKEN when dose has been recorded as taken', () => {
const status = determineDoseStatus({
takenAt: new Date().toISOString(),
snoozedUntil: undefined,
scheduledTime,
now,
});
expect(status).toBe(DoseStatus.TAKEN);
});
it('returns SNOOZED when snooze time is in the future', () => {
const status = determineDoseStatus({
takenAt: undefined,
snoozedUntil: new Date(now.getTime() + 5 * 60 * 1000).toISOString(),
scheduledTime,
now,
});
expect(status).toBe(DoseStatus.SNOOZED);
});
it('returns UPCOMING when snooze time has expired', () => {
const pastSnooze = new Date(now.getTime() - 5 * 60 * 1000).toISOString();
const status = determineDoseStatus({
takenAt: undefined,
snoozedUntil: pastSnooze,
scheduledTime: new Date(now.getTime() - 60 * 60 * 1000),
now,
});
expect(status).toBe(DoseStatus.UPCOMING);
});
it('returns MISSED when scheduled time is in the past without snooze', () => {
const status = determineDoseStatus({
takenAt: undefined,
snoozedUntil: undefined,
scheduledTime: new Date(now.getTime() - 60 * 60 * 1000),
now,
});
expect(status).toBe(DoseStatus.MISSED);
});
it('returns UPCOMING when scheduled time is in the future without snooze', () => {
const status = determineDoseStatus({
takenAt: undefined,
snoozedUntil: undefined,
scheduledTime: new Date(now.getTime() + 60 * 60 * 1000),
now,
});
expect(status).toBe(DoseStatus.UPCOMING);
});
});
+27
View File
@@ -0,0 +1,27 @@
import { normalizeError } from '../error';
describe('normalizeError', () => {
it('returns the same error instance when provided', () => {
const error = new Error('test');
expect(normalizeError(error)).toBe(error);
});
it('converts strings into Error instances', () => {
const result = normalizeError('failure');
expect(result).toBeInstanceOf(Error);
expect(result.message).toBe('failure');
});
it('stringifies objects when creating the error message', () => {
const result = normalizeError({ reason: 'timeout', code: 504 });
expect(result.message).toBe('{"reason":"timeout","code":504}');
});
it('falls back to a generic error for unserializable input', () => {
const circular: Record<string, unknown> = {};
circular.self = circular;
const result = normalizeError(circular);
expect(result.message).toBe('Unknown error');
});
});
+112
View File
@@ -99,6 +99,74 @@ describe('Schedule Utilities', () => {
const schedule = generateSchedule([medication], baseDate);
expect(schedule).toEqual([]);
});
describe('DST boundaries', () => {
test('maintains morning doses during spring forward transition', () => {
const medication = createMockMedication({
frequency: Frequency.Daily,
startTime: '08:00',
});
const beforeTransition = generateSchedule(
[medication],
new Date('2024-03-09T12:00:00.000Z')
);
const duringTransition = generateSchedule(
[medication],
new Date('2024-03-10T12:00:00.000Z')
);
expect(beforeTransition).toHaveLength(1);
expect(duringTransition).toHaveLength(1);
const diffHours =
(duringTransition[0].scheduledTime.getTime() -
beforeTransition[0].scheduledTime.getTime()) /
3600000;
expect(diffHours).toBe(23);
});
test('shifts to next valid slot when scheduled time is skipped by DST', () => {
const medication = createMockMedication({
frequency: Frequency.Daily,
startTime: '02:30',
});
const schedule = generateSchedule(
[medication],
new Date('2024-03-10T12:00:00.000Z')
);
expect(schedule).toHaveLength(1);
expect(schedule[0].scheduledTime.getHours()).toBe(3);
expect(schedule[0].scheduledTime.getMinutes()).toBe(30);
});
test('extends interval by one hour during fall back transition', () => {
const medication = createMockMedication({
frequency: Frequency.Daily,
startTime: '08:00',
});
const beforeTransition = generateSchedule(
[medication],
new Date('2024-11-02T12:00:00.000Z')
);
const duringTransition = generateSchedule(
[medication],
new Date('2024-11-03T12:00:00.000Z')
);
expect(beforeTransition).toHaveLength(1);
expect(duringTransition).toHaveLength(1);
const diffHours =
(duringTransition[0].scheduledTime.getTime() -
beforeTransition[0].scheduledTime.getTime()) /
3600000;
expect(diffHours).toBe(25);
expect(duringTransition[0].scheduledTime.getHours()).toBe(8);
expect(duringTransition[0].scheduledTime.getMinutes()).toBe(0);
});
});
});
describe('generateReminderSchedule', () => {
@@ -186,6 +254,50 @@ describe('Schedule Utilities', () => {
const uniqueIds = new Set(ids);
expect(uniqueIds.size).toBe(ids.length);
});
test('should include reminders at end of window when aligned with frequency', () => {
const reminder = createMockReminder({
startTime: '09:00',
endTime: '10:00',
frequencyMinutes: 30,
});
const schedule = generateReminderSchedule([reminder], baseDate);
const times = schedule.map(instance =>
instance.scheduledTime.toTimeString().slice(0, 5)
);
expect(times).toEqual(['09:00', '09:30', '10:00']);
});
test('should not exceed the time window when frequency does not divide evenly', () => {
const reminder = createMockReminder({
startTime: '09:00',
endTime: '09:40',
frequencyMinutes: 25,
});
const schedule = generateReminderSchedule([reminder], baseDate);
const times = schedule.map(instance =>
instance.scheduledTime.toTimeString().slice(0, 5)
);
expect(times).toEqual(['09:00', '09:25']);
});
test('should handle frequency larger than window by returning a single reminder', () => {
const reminder = createMockReminder({
startTime: '14:00',
endTime: '14:10',
frequencyMinutes: 30,
});
const schedule = generateReminderSchedule([reminder], baseDate);
expect(schedule).toHaveLength(1);
expect(schedule[0].scheduledTime.toTimeString().startsWith('14:00')).toBe(
true
);
});
});
describe('error handling', () => {
+103
View File
@@ -0,0 +1,103 @@
import { tokenStorage } from '../token';
import { logger } from '../../services/logging';
describe('tokenStorage', () => {
const STORAGE_KEY = 'meds_auth_tokens';
const originalLocalStorageDescriptor = Object.getOwnPropertyDescriptor(
window,
'localStorage'
);
beforeEach(() => {
jest.restoreAllMocks();
tokenStorage.clear();
window.localStorage.clear();
});
afterAll(() => {
if (originalLocalStorageDescriptor) {
Object.defineProperty(
window,
'localStorage',
originalLocalStorageDescriptor
);
}
});
it('persists tokens with a single storage key', () => {
const tokens = { accessToken: 'access-123', refreshToken: 'refresh-789' };
tokenStorage.save(tokens);
expect(window.localStorage.getItem(STORAGE_KEY)).toEqual(
JSON.stringify({
accessToken: 'access-123',
refreshToken: 'refresh-789',
})
);
expect(tokenStorage.getAccessToken()).toBe('access-123');
});
it('clears tokens from storage and cache', () => {
tokenStorage.save({
accessToken: 'access-123',
refreshToken: 'refresh-789',
});
tokenStorage.clear();
expect(window.localStorage.getItem(STORAGE_KEY)).toBeNull();
expect(tokenStorage.getTokens()).toBeNull();
expect(tokenStorage.getAccessToken()).toBeNull();
});
it('handles corrupted storage values by clearing and logging', () => {
window.localStorage.setItem(STORAGE_KEY, 'not json');
const warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => {});
expect(tokenStorage.getTokens()).toBeNull();
expect(window.localStorage.getItem(STORAGE_KEY)).toBeNull();
expect(warnSpy).toHaveBeenCalledWith(
'Failed to parse stored tokens, clearing cache',
'AUTH_TOKENS',
expect.any(Error)
);
});
it('falls back to in-memory storage when localStorage is unavailable', () => {
const warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => {});
const error = new Error('blocked');
Object.defineProperty(window, 'localStorage', {
configurable: true,
get: () => {
throw error;
},
});
tokenStorage.clear();
tokenStorage.save({ accessToken: 'access-only' });
expect(tokenStorage.getAccessToken()).toBe('access-only');
expect(warnSpy).toHaveBeenCalledWith(
'Token storage fallback to memory',
'AUTH_TOKENS',
error
);
if (originalLocalStorageDescriptor) {
Object.defineProperty(
window,
'localStorage',
originalLocalStorageDescriptor
);
}
});
it('throws when attempting to save without an access token', () => {
expect(() => tokenStorage.save({ accessToken: '' })).toThrow(
'Token payload must include an access token'
);
});
});
+7
View File
@@ -0,0 +1,7 @@
export const encodeBase64 = (value: string): string => {
if (typeof btoa !== 'undefined') {
return btoa(value);
}
return Buffer.from(value, 'utf-8').toString('base64');
};
+35
View File
@@ -0,0 +1,35 @@
import { DoseStatus } from '../types';
export interface DoseStatusParams {
takenAt?: string;
snoozedUntil?: string;
scheduledTime: Date;
now: Date;
}
export const determineDoseStatus = ({
takenAt,
snoozedUntil,
scheduledTime,
now,
}: DoseStatusParams): DoseStatus => {
if (takenAt) {
return DoseStatus.TAKEN;
}
if (snoozedUntil) {
const snoozeTime = new Date(snoozedUntil);
if (!Number.isNaN(snoozeTime.getTime())) {
if (snoozeTime.getTime() > now.getTime()) {
return DoseStatus.SNOOZED;
}
return DoseStatus.UPCOMING;
}
}
if (scheduledTime.getTime() < now.getTime()) {
return DoseStatus.MISSED;
}
return DoseStatus.UPCOMING;
};
+15
View File
@@ -0,0 +1,15 @@
export const normalizeError = (error: unknown): Error => {
if (error instanceof Error) {
return error;
}
if (typeof error === 'string') {
return new Error(error);
}
try {
return new Error(JSON.stringify(error));
} catch {
return new Error('Unknown error');
}
};
+101
View File
@@ -0,0 +1,101 @@
import { logger } from '../services/logging';
export interface AuthTokens {
accessToken: string;
refreshToken?: string | null;
}
const TOKEN_STORAGE_KEY = 'meds_auth_tokens';
let memoryTokens: AuthTokens | null = null;
function getStorage(): Storage | null {
if (typeof window === 'undefined') {
return null;
}
try {
return window.localStorage;
} catch (error) {
// LocalStorage may be unavailable (e.g. Safari private mode); gracefully degrade.
logger.warn('Token storage fallback to memory', 'AUTH_TOKENS', error);
return null;
}
}
function persist(tokens: AuthTokens | null): void {
const storage = getStorage();
if (!storage) {
memoryTokens = tokens;
return;
}
if (!tokens) {
storage.removeItem(TOKEN_STORAGE_KEY);
memoryTokens = null;
return;
}
const payload: AuthTokens = {
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken ?? null,
};
storage.setItem(TOKEN_STORAGE_KEY, JSON.stringify(payload));
memoryTokens = payload;
}
function readTokens(): AuthTokens | null {
const storage = getStorage();
if (!storage) {
return memoryTokens;
}
try {
const raw = storage.getItem(TOKEN_STORAGE_KEY);
if (!raw) {
memoryTokens = null;
return null;
}
const parsed = JSON.parse(raw) as AuthTokens;
memoryTokens = parsed;
return parsed;
} catch (error) {
logger.warn(
'Failed to parse stored tokens, clearing cache',
'AUTH_TOKENS',
error
);
storage.removeItem(TOKEN_STORAGE_KEY);
memoryTokens = null;
return null;
}
}
export const tokenStorage = {
save(tokens: AuthTokens): void {
if (!tokens || !tokens.accessToken) {
throw new Error('Token payload must include an access token');
}
persist(tokens);
},
getTokens(): AuthTokens | null {
return readTokens();
},
getAccessToken(): string | null {
const tokens = readTokens();
return tokens?.accessToken ?? null;
},
clear(): void {
persist(null);
},
};
export default tokenStorage;