Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 10d1de91fe | |||
| 16bd4a8b20 | |||
| dec8c7b42e | |||
| eb43766b21 | |||
| de237fd997 | |||
| e3a924c0c6 | |||
| f9ccb50222 | |||
| fcfe2a38e2 | |||
| e9a662d1e2 | |||
| 7c712ae84b | |||
| 9bed793997 | |||
| 35dcae07e5 | |||
| 2cb56d5f5f | |||
| 9b4ee116e6 | |||
| e7dbe30763 | |||
| 71c37f4b7b | |||
| c1c8e28f01 | |||
| 6b6a44acef |
@@ -32,6 +32,7 @@ import {
|
|||||||
AuthPage,
|
AuthPage,
|
||||||
AvatarDropdown,
|
AvatarDropdown,
|
||||||
ChangePasswordModal,
|
ChangePasswordModal,
|
||||||
|
ResetPasswordPage,
|
||||||
} from './components/auth';
|
} from './components/auth';
|
||||||
import { AdminInterface } from './components/admin';
|
import { AdminInterface } from './components/admin';
|
||||||
import {
|
import {
|
||||||
@@ -62,6 +63,9 @@ import {
|
|||||||
import { useUser } from './contexts/UserContext';
|
import { useUser } from './contexts/UserContext';
|
||||||
import { databaseService } from './services/database';
|
import { databaseService } from './services/database';
|
||||||
import { databaseSeeder } from './services/database.seeder';
|
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<{
|
const Header: React.FC<{
|
||||||
onAdd: () => void;
|
onAdd: () => void;
|
||||||
@@ -230,7 +234,9 @@ const MedicationScheduleApp: React.FC<{ user: User }> = ({ user }) => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Don't try to fetch data if user._id is not available
|
// Don't try to fetch data if user._id is not available
|
||||||
if (!user._id) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,7 +245,9 @@ const MedicationScheduleApp: React.FC<{ user: User }> = ({ user }) => {
|
|||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
setError(null);
|
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] =
|
const [medsData, remindersData, takenDosesData, settingsData] =
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
@@ -249,9 +257,10 @@ const MedicationScheduleApp: React.FC<{ user: User }> = ({ user }) => {
|
|||||||
databaseService.getUserSettings(user._id),
|
databaseService.getUserSettings(user._id),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
console.warn('Data fetched successfully:', {
|
logger.db.query('Fetched user data successfully', {
|
||||||
medications: medsData.length,
|
userId: user._id,
|
||||||
reminders: remindersData.length,
|
medicationCount: medsData.length,
|
||||||
|
reminderCount: remindersData.length,
|
||||||
hasTakenDoses: !!takenDosesData,
|
hasTakenDoses: !!takenDosesData,
|
||||||
hasSettings: !!settingsData,
|
hasSettings: !!settingsData,
|
||||||
});
|
});
|
||||||
@@ -266,8 +275,9 @@ const MedicationScheduleApp: React.FC<{ user: User }> = ({ user }) => {
|
|||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError('Failed to load your data. Please try again.');
|
setError('Failed to load your data. Please try again.');
|
||||||
console.error('Error loading user data:', e);
|
logger.db.error('Error loading user data', normalizeError(e), {
|
||||||
console.error('User object:', user);
|
userId: user._id,
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
@@ -368,16 +378,35 @@ const MedicationScheduleApp: React.FC<{ user: User }> = ({ user }) => {
|
|||||||
async (doseId: string) => {
|
async (doseId: string) => {
|
||||||
if (!takenDosesDoc) return;
|
if (!takenDosesDoc) return;
|
||||||
const newDoses = { ...takenDosesDoc.doses };
|
const newDoses = { ...takenDosesDoc.doses };
|
||||||
if (newDoses[doseId]) {
|
const wasTaken = Boolean(newDoses[doseId]);
|
||||||
|
|
||||||
|
if (wasTaken) {
|
||||||
delete newDoses[doseId];
|
delete newDoses[doseId];
|
||||||
} else {
|
} else {
|
||||||
newDoses[doseId] = new Date().toISOString();
|
newDoses[doseId] = new Date().toISOString();
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedDoc = await databaseService.updateTakenDoses({
|
const updatedDoc = await databaseService.updateTakenDoses({
|
||||||
...takenDosesDoc,
|
...takenDosesDoc,
|
||||||
doses: newDoses,
|
doses: newDoses,
|
||||||
});
|
});
|
||||||
setTakenDosesDoc(updatedDoc);
|
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]
|
[takenDosesDoc]
|
||||||
);
|
);
|
||||||
@@ -395,13 +424,13 @@ const MedicationScheduleApp: React.FC<{ user: User }> = ({ user }) => {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const getDoseStatus = useCallback(
|
const getDoseStatus = useCallback(
|
||||||
(dose: Dose, doseTime: Date, now: Date): DoseStatus => {
|
(dose: Dose, doseTime: Date, now: Date): DoseStatus =>
|
||||||
if (takenDoses[dose.id]) return DoseStatus.TAKEN;
|
determineDoseStatus({
|
||||||
if (snoozedDoses[dose.id] && new Date(snoozedDoses[dose.id]) > now)
|
takenAt: takenDoses[dose.id],
|
||||||
return DoseStatus.SNOOZED;
|
snoozedUntil: snoozedDoses[dose.id],
|
||||||
if (doseTime.getTime() < now.getTime()) return DoseStatus.MISSED;
|
scheduledTime: doseTime,
|
||||||
return DoseStatus.UPCOMING;
|
now,
|
||||||
},
|
}),
|
||||||
[takenDoses, snoozedDoses]
|
[takenDoses, snoozedDoses]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -413,15 +442,20 @@ const MedicationScheduleApp: React.FC<{ user: User }> = ({ user }) => {
|
|||||||
const medication = medications.find(m => m._id === item.medicationId);
|
const medication = medications.find(m => m._id === item.medicationId);
|
||||||
if (!medication) return null;
|
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 {
|
return {
|
||||||
...item,
|
...item,
|
||||||
type: 'dose' as const,
|
type: 'dose' as const,
|
||||||
medication,
|
medication,
|
||||||
status: getDoseStatus(item, item.scheduledTime, currentTime),
|
status: getDoseStatus(item, item.scheduledTime, currentTime),
|
||||||
takenAt: takenDoses[item.id],
|
takenAt: takenDoses[item.id],
|
||||||
snoozedUntil: snoozedDoses[item.id]
|
snoozedUntil: validSnooze,
|
||||||
? new Date(snoozedDoses[item.id])
|
|
||||||
: undefined,
|
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
// It's a Custom Reminder
|
// It's a Custom Reminder
|
||||||
@@ -460,9 +494,10 @@ const MedicationScheduleApp: React.FC<{ user: User }> = ({ user }) => {
|
|||||||
let timeToNotification = -1;
|
let timeToNotification = -1;
|
||||||
let notificationBody = '';
|
let notificationBody = '';
|
||||||
let notificationTitle = '';
|
let notificationTitle = '';
|
||||||
|
let targetTime: Date | null = null;
|
||||||
|
|
||||||
if (item.type === 'dose' && item.status === DoseStatus.UPCOMING) {
|
if (item.type === 'dose' && item.status === DoseStatus.UPCOMING) {
|
||||||
timeToNotification = item.scheduledTime.getTime() - now.getTime();
|
targetTime = item.snoozedUntil ?? item.scheduledTime;
|
||||||
notificationTitle = 'Time for your medication!';
|
notificationTitle = 'Time for your medication!';
|
||||||
notificationBody = `${item.medication.name} (${item.medication.dosage})`;
|
notificationBody = `${item.medication.name} (${item.medication.dosage})`;
|
||||||
} else if (
|
} else if (
|
||||||
@@ -470,17 +505,21 @@ const MedicationScheduleApp: React.FC<{ user: User }> = ({ user }) => {
|
|||||||
item.status === DoseStatus.SNOOZED &&
|
item.status === DoseStatus.SNOOZED &&
|
||||||
item.snoozedUntil
|
item.snoozedUntil
|
||||||
) {
|
) {
|
||||||
timeToNotification = item.snoozedUntil.getTime() - now.getTime();
|
targetTime = item.snoozedUntil;
|
||||||
notificationTitle = 'Snoozed Medication Reminder';
|
notificationTitle = 'Snoozed Medication Reminder';
|
||||||
notificationBody = `${item.medication.name} (${item.medication.dosage})`;
|
notificationBody = `${item.medication.name} (${item.medication.dosage})`;
|
||||||
} else if (item.type === 'reminder' && item.scheduledTime > now) {
|
} else if (item.type === 'reminder' && item.scheduledTime > now) {
|
||||||
timeToNotification = item.scheduledTime.getTime() - now.getTime();
|
targetTime = item.scheduledTime;
|
||||||
notificationTitle = 'Reminder';
|
notificationTitle = 'Reminder';
|
||||||
notificationBody = item.title;
|
notificationBody = item.title;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (targetTime) {
|
||||||
|
timeToNotification = targetTime.getTime() - now.getTime();
|
||||||
|
}
|
||||||
|
|
||||||
if (timeToNotification > 0) {
|
if (timeToNotification > 0) {
|
||||||
activeTimers[itemId] = setTimeout(() => {
|
const timerId = window.setTimeout(() => {
|
||||||
new Notification(notificationTitle, {
|
new Notification(notificationTitle, {
|
||||||
body: notificationBody,
|
body: notificationBody,
|
||||||
tag: itemId,
|
tag: itemId,
|
||||||
@@ -488,16 +527,23 @@ const MedicationScheduleApp: React.FC<{ user: User }> = ({ user }) => {
|
|||||||
if (item.type === 'dose' && item.status === DoseStatus.SNOOZED) {
|
if (item.type === 'dose' && item.status === DoseStatus.SNOOZED) {
|
||||||
setSnoozedDoses(prev => {
|
setSnoozedDoses(prev => {
|
||||||
const newSnoozed = { ...prev };
|
const newSnoozed = { ...prev };
|
||||||
delete newSnoozed[itemId];
|
newSnoozed[itemId] = new Date().toISOString();
|
||||||
return newSnoozed;
|
return newSnoozed;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
delete activeTimers[itemId];
|
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]);
|
}, [scheduleWithStatus, settings?.notificationsEnabled]);
|
||||||
|
|
||||||
const filteredSchedule = useMemo(
|
const filteredSchedule = useMemo(
|
||||||
@@ -688,7 +734,11 @@ const MedicationScheduleApp: React.FC<{ user: User }> = ({ user }) => {
|
|||||||
setSettings(updatedSettings);
|
setSettings(updatedSettings);
|
||||||
setOnboardingOpen(false);
|
setOnboardingOpen(false);
|
||||||
} catch (error) {
|
} 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);
|
setOnboardingOpen(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -910,10 +960,10 @@ const App: React.FC = () => {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const runSeeding = async () => {
|
const runSeeding = async () => {
|
||||||
try {
|
try {
|
||||||
console.warn('🌱 Initializing database seeding...');
|
logger.db.query('Initializing database seeding');
|
||||||
await databaseSeeder.seedDatabase();
|
await databaseSeeder.seedDatabase();
|
||||||
} catch (error) {
|
} 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 (!user) {
|
||||||
|
if (
|
||||||
|
typeof window !== 'undefined' &&
|
||||||
|
window.location.pathname === '/reset-password'
|
||||||
|
) {
|
||||||
|
return <ResetPasswordPage />;
|
||||||
|
}
|
||||||
return <AuthPage />;
|
return <AuthPage />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,12 @@ DOCKER_IMAGE ?= $(APP_NAME):latest
|
|||||||
|
|
||||||
export
|
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 target
|
||||||
.DEFAULT_GOAL := help
|
.DEFAULT_GOAL := help
|
||||||
@@ -53,6 +58,10 @@ dev: ## Start development server
|
|||||||
@echo "Starting $(APP_NAME) development server..."
|
@echo "Starting $(APP_NAME) development server..."
|
||||||
@bun run dev
|
@bun run dev
|
||||||
|
|
||||||
|
preview: ## Serve production build locally
|
||||||
|
@echo "Starting $(APP_NAME) preview server..."
|
||||||
|
@bun run preview
|
||||||
|
|
||||||
build: ## Build the application
|
build: ## Build the application
|
||||||
@echo "Building $(APP_NAME) application..."
|
@echo "Building $(APP_NAME) application..."
|
||||||
@bun run build
|
@bun run build
|
||||||
@@ -67,6 +76,44 @@ test-watch: ## Run unit tests in watch mode
|
|||||||
@echo "Running $(APP_NAME) tests in watch mode..."
|
@echo "Running $(APP_NAME) tests in watch mode..."
|
||||||
@bun run test:watch
|
@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
|
||||||
|
|
||||||
docker-build: ## Build Docker image for local development
|
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 image prune -f 2>/dev/null || true
|
||||||
@docker container 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
|
##@ Test Services
|
||||||
|
|
||||||
couchdb-up: ## Start local CouchDB for integration tests
|
couchdb-up: ## Start local CouchDB for integration tests
|
||||||
|
|||||||
@@ -39,6 +39,16 @@ A modern, secure web application for managing medication schedules and reminders
|
|||||||
- **Progress Tracking** over time
|
- **Progress Tracking** over time
|
||||||
- **Export Capabilities** for healthcare providers
|
- **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**
|
### 🎨 **User Experience**
|
||||||
|
|
||||||
- **Responsive Design** for mobile and desktop
|
- **Responsive Design** for mobile and desktop
|
||||||
@@ -226,7 +236,17 @@ The application automatically selects the appropriate database strategy:
|
|||||||
|
|
||||||
## 🐳 Docker Development
|
## 🐳 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
|
```bash
|
||||||
# Build Docker image
|
# Build Docker image
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
import { User, UserRole } from '../../types';
|
import { User, UserRole } from '../../types';
|
||||||
import { AccountStatus } from '../../services/auth/auth.constants';
|
import { AccountStatus } from '../../services/auth/auth.constants';
|
||||||
import { databaseService } from '../../services/database';
|
import { databaseService } from '../../services/database';
|
||||||
import { useUser } from '../../contexts/UserContext';
|
import { useUser } from '../../contexts/UserContext';
|
||||||
|
import { logger } from '../../services/logging';
|
||||||
|
|
||||||
interface AdminInterfaceProps {
|
interface AdminInterfaceProps {
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
@@ -15,61 +16,114 @@ const AdminInterface: React.FC<AdminInterfaceProps> = ({ onClose }) => {
|
|||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [selectedUser, setSelectedUser] = useState<User | null>(null);
|
const [selectedUser, setSelectedUser] = useState<User | null>(null);
|
||||||
const [newPassword, setNewPassword] = useState('');
|
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(() => {
|
useEffect(() => {
|
||||||
loadUsers();
|
loadUsers();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const loadUsers = async () => {
|
const loadUsers = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
try {
|
try {
|
||||||
const users = await databaseService.getAllUsers();
|
const users = await databaseService.getAllUsers();
|
||||||
setUsers(users);
|
setUsers(users);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setError('Failed to load users');
|
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 {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSuspendUser = async (userId: string) => {
|
const handleSuspendUser = async (user: User) => {
|
||||||
try {
|
try {
|
||||||
await databaseService.suspendUser(userId);
|
await databaseService.suspendUser(user._id);
|
||||||
|
pushToast(`${user.username} suspended`, 'info');
|
||||||
await loadUsers();
|
await loadUsers();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setError('Failed to suspend user');
|
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 {
|
try {
|
||||||
await databaseService.activateUser(userId);
|
await databaseService.activateUser(user._id);
|
||||||
|
pushToast(`${user.username} reactivated`, 'success');
|
||||||
await loadUsers();
|
await loadUsers();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setError('Failed to activate user');
|
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) => {
|
const confirmDeleteUser = (user: User) => {
|
||||||
if (
|
setUserPendingDeletion(user);
|
||||||
!confirm(
|
setError('');
|
||||||
'Are you sure you want to delete this user? This action cannot be undone.'
|
};
|
||||||
)
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
const executeDeleteUser = async () => {
|
||||||
|
if (!userPendingDeletion) return;
|
||||||
|
|
||||||
|
setIsDeletingUser(true);
|
||||||
try {
|
try {
|
||||||
await databaseService.deleteUser(userId);
|
await databaseService.deleteUser(userPendingDeletion._id);
|
||||||
|
pushToast(`${userPendingDeletion.username} deleted`, 'info');
|
||||||
await loadUsers();
|
await loadUsers();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setError('Failed to delete user');
|
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) => {
|
const handleChangePassword = async (userId: string) => {
|
||||||
if (!newPassword || newPassword.length < 6) {
|
if (!newPassword || newPassword.length < 6) {
|
||||||
setError('Password must be at least 6 characters long');
|
setError('Password must be at least 6 characters long');
|
||||||
@@ -81,10 +135,11 @@ const AdminInterface: React.FC<AdminInterfaceProps> = ({ onClose }) => {
|
|||||||
setNewPassword('');
|
setNewPassword('');
|
||||||
setSelectedUser(null);
|
setSelectedUser(null);
|
||||||
setError('');
|
setError('');
|
||||||
alert('Password changed successfully');
|
pushToast('Password changed successfully', 'success');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setError('Failed to change password');
|
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';
|
: '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) {
|
if (currentUser?.role !== UserRole.ADMIN) {
|
||||||
return (
|
return (
|
||||||
<div className='fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50'>
|
<div className='fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50'>
|
||||||
@@ -127,6 +264,26 @@ const AdminInterface: React.FC<AdminInterfaceProps> = ({ onClose }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
<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={() => 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'
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
<div className='fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4'>
|
<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='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='p-6 border-b border-slate-200 dark:border-slate-600'>
|
||||||
@@ -173,7 +330,7 @@ const AdminInterface: React.FC<AdminInterfaceProps> = ({ onClose }) => {
|
|||||||
<div className='space-y-4'>
|
<div className='space-y-4'>
|
||||||
<div className='flex justify-between items-center'>
|
<div className='flex justify-between items-center'>
|
||||||
<h3 className='text-lg font-semibold text-slate-800 dark:text-slate-100'>
|
<h3 className='text-lg font-semibold text-slate-800 dark:text-slate-100'>
|
||||||
User Management ({users.length} users)
|
User Management ({visibleUsersLabel})
|
||||||
</h3>
|
</h3>
|
||||||
<button
|
<button
|
||||||
onClick={loadUsers}
|
onClick={loadUsers}
|
||||||
@@ -184,6 +341,67 @@ const AdminInterface: React.FC<AdminInterfaceProps> = ({ onClose }) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className='overflow-x-auto'>
|
<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'
|
||||||
|
>
|
||||||
|
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'>
|
<table className='min-w-full bg-white dark:bg-slate-700 rounded-lg overflow-hidden'>
|
||||||
<thead className='bg-slate-50 dark:bg-slate-600'>
|
<thead className='bg-slate-50 dark:bg-slate-600'>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -208,7 +426,7 @@ const AdminInterface: React.FC<AdminInterfaceProps> = ({ onClose }) => {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className='divide-y divide-slate-200 dark:divide-slate-600'>
|
<tbody className='divide-y divide-slate-200 dark:divide-slate-600'>
|
||||||
{users.map(user => (
|
{filteredUsers.map(user => (
|
||||||
<tr
|
<tr
|
||||||
key={user._id}
|
key={user._id}
|
||||||
className='hover:bg-slate-50 dark:hover:bg-slate-600'
|
className='hover:bg-slate-50 dark:hover:bg-slate-600'
|
||||||
@@ -267,7 +485,7 @@ const AdminInterface: React.FC<AdminInterfaceProps> = ({ onClose }) => {
|
|||||||
<div className='flex space-x-2'>
|
<div className='flex space-x-2'>
|
||||||
{user.status === AccountStatus.ACTIVE ? (
|
{user.status === AccountStatus.ACTIVE ? (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleSuspendUser(user._id)}
|
onClick={() => handleSuspendUser(user)}
|
||||||
className='text-red-600 hover:text-red-800 text-xs'
|
className='text-red-600 hover:text-red-800 text-xs'
|
||||||
disabled={user._id === currentUser?._id}
|
disabled={user._id === currentUser?._id}
|
||||||
>
|
>
|
||||||
@@ -275,7 +493,7 @@ const AdminInterface: React.FC<AdminInterfaceProps> = ({ onClose }) => {
|
|||||||
</button>
|
</button>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
onClick={() => handleActivateUser(user._id)}
|
onClick={() => handleActivateUser(user)}
|
||||||
className='text-green-600 hover:text-green-800 text-xs'
|
className='text-green-600 hover:text-green-800 text-xs'
|
||||||
>
|
>
|
||||||
Activate
|
Activate
|
||||||
@@ -292,7 +510,7 @@ const AdminInterface: React.FC<AdminInterfaceProps> = ({ onClose }) => {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDeleteUser(user._id)}
|
onClick={() => confirmDeleteUser(user)}
|
||||||
className='text-red-600 hover:text-red-800 text-xs'
|
className='text-red-600 hover:text-red-800 text-xs'
|
||||||
disabled={
|
disabled={
|
||||||
user._id === currentUser?._id ||
|
user._id === currentUser?._id ||
|
||||||
@@ -307,6 +525,7 @@ const AdminInterface: React.FC<AdminInterfaceProps> = ({ onClose }) => {
|
|||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -353,8 +572,38 @@ const AdminInterface: React.FC<AdminInterfaceProps> = ({ onClose }) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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={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'
|
||||||
|
>
|
||||||
|
{isDeletingUser ? 'Deleting...' : 'Delete'}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
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>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import { Medication, Frequency } from '../../types';
|
import { Medication, Frequency } from '../../types';
|
||||||
import { medicationIcons } from '../icons/Icons';
|
import { medicationIcons } from '../icons/Icons';
|
||||||
|
import { logger } from '../../services/logging';
|
||||||
|
|
||||||
interface AddMedicationModalProps {
|
interface AddMedicationModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -67,7 +68,7 @@ const AddMedicationModal: React.FC<AddMedicationModalProps> = ({
|
|||||||
icon,
|
icon,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} 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.');
|
alert('There was an error saving your medication. Please try again.');
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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> = ({
|
const DoseCard: React.FC<DoseCardProps> = ({
|
||||||
dose,
|
dose,
|
||||||
medication,
|
medication,
|
||||||
@@ -68,6 +75,7 @@ const DoseCard: React.FC<DoseCardProps> = ({
|
|||||||
snoozedUntil,
|
snoozedUntil,
|
||||||
}) => {
|
}) => {
|
||||||
const styles = statusStyles[status];
|
const styles = statusStyles[status];
|
||||||
|
const statusLabel = statusLabels[status];
|
||||||
const timeString = dose.scheduledTime.toLocaleTimeString([], {
|
const timeString = dose.scheduledTime.toLocaleTimeString([], {
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
@@ -85,17 +93,48 @@ const DoseCard: React.FC<DoseCardProps> = ({
|
|||||||
})
|
})
|
||||||
: '';
|
: '';
|
||||||
const MedicationIcon = getMedicationIcon(medication.icon);
|
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 (
|
return (
|
||||||
<li
|
<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>
|
||||||
<div className='flex justify-between items-start'>
|
<div className='flex justify-between items-start'>
|
||||||
<div className='flex items-center space-x-3'>
|
<div className='flex items-center space-x-3'>
|
||||||
<MedicationIcon className='w-7 h-7 text-indigo-500 dark:text-indigo-400 flex-shrink-0' />
|
<MedicationIcon className='w-7 h-7 text-indigo-500 dark:text-indigo-400 flex-shrink-0' />
|
||||||
<div>
|
<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}
|
{medication.name}
|
||||||
</h4>
|
</h4>
|
||||||
<p className='text-slate-600 dark:text-slate-300'>
|
<p className='text-slate-600 dark:text-slate-300'>
|
||||||
@@ -106,9 +145,14 @@ const DoseCard: React.FC<DoseCardProps> = ({
|
|||||||
{styles.icon}
|
{styles.icon}
|
||||||
</div>
|
</div>
|
||||||
<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}`}
|
className={`flex items-center space-x-2 mt-4 font-semibold text-lg ${styles.text}`}
|
||||||
>
|
>
|
||||||
<ClockIcon className='w-5 h-5' />
|
<ClockIcon className='w-5 h-5' />
|
||||||
|
<span className='sr-only'>{statusLabel}</span>
|
||||||
<span>{timeString}</span>
|
<span>{timeString}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import { Medication, Frequency } from '../../types';
|
import { Medication, Frequency } from '../../types';
|
||||||
import { medicationIcons } from '../icons/Icons';
|
import { medicationIcons } from '../icons/Icons';
|
||||||
|
import { logger } from '../../services/logging';
|
||||||
|
|
||||||
interface EditMedicationModalProps {
|
interface EditMedicationModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -72,7 +73,7 @@ const EditMedicationModal: React.FC<EditMedicationModalProps> = ({
|
|||||||
icon,
|
icon,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} 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.');
|
alert('There was an error updating your medication. Please try again.');
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import { CustomReminder } from '../../types';
|
import { CustomReminder } from '../../types';
|
||||||
import { reminderIcons } from '../icons/Icons';
|
import { reminderIcons } from '../icons/Icons';
|
||||||
|
import {
|
||||||
|
MIN_REMINDER_FREQUENCY_MINUTES,
|
||||||
|
MAX_REMINDER_FREQUENCY_MINUTES,
|
||||||
|
validateReminderInputs,
|
||||||
|
} from './reminderValidation';
|
||||||
|
import { logger } from '../../services/logging';
|
||||||
|
|
||||||
interface AddReminderModalProps {
|
interface AddReminderModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -19,9 +25,21 @@ const AddReminderModal: React.FC<AddReminderModalProps> = ({
|
|||||||
const [startTime, setStartTime] = useState('09:00');
|
const [startTime, setStartTime] = useState('09:00');
|
||||||
const [endTime, setEndTime] = useState('17:00');
|
const [endTime, setEndTime] = useState('17:00');
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [errors, setErrors] = useState<{
|
||||||
|
frequency?: string;
|
||||||
|
timeRange?: string;
|
||||||
|
}>({});
|
||||||
|
|
||||||
const titleInputRef = useRef<HTMLInputElement>(null);
|
const titleInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const validate = () => {
|
||||||
|
return validateReminderInputs({
|
||||||
|
frequencyMinutes,
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
setTitle('');
|
setTitle('');
|
||||||
@@ -30,14 +48,26 @@ const AddReminderModal: React.FC<AddReminderModalProps> = ({
|
|||||||
setStartTime('09:00');
|
setStartTime('09:00');
|
||||||
setEndTime('17:00');
|
setEndTime('17:00');
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
|
setErrors({});
|
||||||
setTimeout(() => titleInputRef.current?.focus(), 100);
|
setTimeout(() => titleInputRef.current?.focus(), 100);
|
||||||
}
|
}
|
||||||
}, [isOpen]);
|
}, [isOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
setErrors(validate());
|
||||||
|
}, [isOpen, frequencyMinutes, startTime, endTime]);
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!title || isSaving) return;
|
if (!title || isSaving) return;
|
||||||
|
|
||||||
|
const validation = validate();
|
||||||
|
setErrors(validation);
|
||||||
|
if (Object.keys(validation).length > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
try {
|
try {
|
||||||
await onAdd({
|
await onAdd({
|
||||||
@@ -48,7 +78,7 @@ const AddReminderModal: React.FC<AddReminderModalProps> = ({
|
|||||||
endTime,
|
endTime,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} 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.');
|
alert('There was an error saving your reminder. Please try again.');
|
||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
@@ -125,11 +155,30 @@ const AddReminderModal: React.FC<AddReminderModalProps> = ({
|
|||||||
id='rem-frequency'
|
id='rem-frequency'
|
||||||
value={frequencyMinutes}
|
value={frequencyMinutes}
|
||||||
onChange={e =>
|
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'
|
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>
|
||||||
|
|
||||||
<div className='grid grid-cols-2 gap-4'>
|
<div className='grid grid-cols-2 gap-4'>
|
||||||
@@ -146,6 +195,10 @@ const AddReminderModal: React.FC<AddReminderModalProps> = ({
|
|||||||
value={startTime}
|
value={startTime}
|
||||||
onChange={e => setStartTime(e.target.value)}
|
onChange={e => setStartTime(e.target.value)}
|
||||||
required
|
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'
|
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>
|
||||||
@@ -162,10 +215,22 @@ const AddReminderModal: React.FC<AddReminderModalProps> = ({
|
|||||||
value={endTime}
|
value={endTime}
|
||||||
onChange={e => setEndTime(e.target.value)}
|
onChange={e => setEndTime(e.target.value)}
|
||||||
required
|
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'
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
{errors.timeRange && (
|
||||||
|
<p
|
||||||
|
id='rem-time-help'
|
||||||
|
className='text-sm text-red-600 dark:text-red-400'
|
||||||
|
>
|
||||||
|
{errors.timeRange}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</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'>
|
<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
|
<button
|
||||||
@@ -178,7 +243,9 @@ const AddReminderModal: React.FC<AddReminderModalProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type='submit'
|
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'
|
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'}
|
{isSaving ? 'Adding...' : 'Add Reminder'}
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import { CustomReminder } from '../../types';
|
import { CustomReminder } from '../../types';
|
||||||
import { reminderIcons } from '../icons/Icons';
|
import { reminderIcons } from '../icons/Icons';
|
||||||
|
import {
|
||||||
|
MIN_REMINDER_FREQUENCY_MINUTES,
|
||||||
|
MAX_REMINDER_FREQUENCY_MINUTES,
|
||||||
|
validateReminderInputs,
|
||||||
|
} from './reminderValidation';
|
||||||
|
import { logger } from '../../services/logging';
|
||||||
|
|
||||||
interface EditReminderModalProps {
|
interface EditReminderModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -21,9 +27,20 @@ const EditReminderModal: React.FC<EditReminderModalProps> = ({
|
|||||||
const [startTime, setStartTime] = useState('09:00');
|
const [startTime, setStartTime] = useState('09:00');
|
||||||
const [endTime, setEndTime] = useState('17:00');
|
const [endTime, setEndTime] = useState('17:00');
|
||||||
const [isSaving, setIsSaving] = useState(false);
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [errors, setErrors] = useState<{
|
||||||
|
frequency?: string;
|
||||||
|
timeRange?: string;
|
||||||
|
}>({});
|
||||||
|
|
||||||
const titleInputRef = useRef<HTMLInputElement>(null);
|
const titleInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const validate = () =>
|
||||||
|
validateReminderInputs({
|
||||||
|
frequencyMinutes,
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen && reminder) {
|
if (isOpen && reminder) {
|
||||||
setTitle(reminder.title);
|
setTitle(reminder.title);
|
||||||
@@ -32,14 +49,26 @@ const EditReminderModal: React.FC<EditReminderModalProps> = ({
|
|||||||
setStartTime(reminder.startTime);
|
setStartTime(reminder.startTime);
|
||||||
setEndTime(reminder.endTime);
|
setEndTime(reminder.endTime);
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
|
setErrors({});
|
||||||
setTimeout(() => titleInputRef.current?.focus(), 100);
|
setTimeout(() => titleInputRef.current?.focus(), 100);
|
||||||
}
|
}
|
||||||
}, [isOpen, reminder]);
|
}, [isOpen, reminder]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
setErrors(validate());
|
||||||
|
}, [isOpen, frequencyMinutes, startTime, endTime]);
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!title || !reminder || isSaving) return;
|
if (!title || !reminder || isSaving) return;
|
||||||
|
|
||||||
|
const validation = validate();
|
||||||
|
setErrors(validation);
|
||||||
|
if (Object.keys(validation).length > 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
try {
|
try {
|
||||||
await onUpdate({
|
await onUpdate({
|
||||||
@@ -51,7 +80,7 @@ const EditReminderModal: React.FC<EditReminderModalProps> = ({
|
|||||||
endTime,
|
endTime,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} 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.');
|
alert('There was an error updating your reminder. Please try again.');
|
||||||
} finally {
|
} finally {
|
||||||
setIsSaving(false);
|
setIsSaving(false);
|
||||||
@@ -126,12 +155,28 @@ const EditReminderModal: React.FC<EditReminderModalProps> = ({
|
|||||||
type='number'
|
type='number'
|
||||||
id='rem-edit-frequency'
|
id='rem-edit-frequency'
|
||||||
value={frequencyMinutes}
|
value={frequencyMinutes}
|
||||||
onChange={e =>
|
onChange={e => {
|
||||||
setFrequencyMinutes(parseInt(e.target.value, 10))
|
const value = parseInt(e.target.value, 10);
|
||||||
}
|
setFrequencyMinutes(Number.isNaN(value) ? 0 : value);
|
||||||
min='1'
|
}}
|
||||||
|
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'
|
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>
|
||||||
|
|
||||||
<div className='grid grid-cols-2 gap-4'>
|
<div className='grid grid-cols-2 gap-4'>
|
||||||
@@ -148,6 +193,10 @@ const EditReminderModal: React.FC<EditReminderModalProps> = ({
|
|||||||
value={startTime}
|
value={startTime}
|
||||||
onChange={e => setStartTime(e.target.value)}
|
onChange={e => setStartTime(e.target.value)}
|
||||||
required
|
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'
|
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>
|
||||||
@@ -164,10 +213,22 @@ const EditReminderModal: React.FC<EditReminderModalProps> = ({
|
|||||||
value={endTime}
|
value={endTime}
|
||||||
onChange={e => setEndTime(e.target.value)}
|
onChange={e => setEndTime(e.target.value)}
|
||||||
required
|
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'
|
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>
|
||||||
</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>
|
||||||
<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'>
|
<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
|
<button
|
||||||
@@ -180,7 +241,9 @@ const EditReminderModal: React.FC<EditReminderModalProps> = ({
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type='submit'
|
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'
|
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'}
|
{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',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -12,15 +12,23 @@ const ReminderCard: React.FC<ReminderCardProps> = ({ reminder }) => {
|
|||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
});
|
});
|
||||||
const ReminderIcon = getReminderIcon(reminder.icon);
|
const ReminderIcon = getReminderIcon(reminder.icon);
|
||||||
|
const titleId = `reminder-${reminder.id}-title`;
|
||||||
|
|
||||||
return (
|
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>
|
||||||
<div className='flex justify-between items-start'>
|
<div className='flex justify-between items-start'>
|
||||||
<div className='flex items-center space-x-3'>
|
<div className='flex items-center space-x-3'>
|
||||||
<ReminderIcon className='w-7 h-7 text-sky-500 dark:text-sky-400 flex-shrink-0' />
|
<ReminderIcon className='w-7 h-7 text-sky-500 dark:text-sky-400 flex-shrink-0' />
|
||||||
<div>
|
<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}
|
{reminder.title}
|
||||||
</h4>
|
</h4>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { logger } from '../services/logging';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Unified Application Configuration System
|
* Unified Application Configuration System
|
||||||
*
|
*
|
||||||
@@ -754,10 +756,10 @@ function validateConfig(config: UnifiedConfig): void {
|
|||||||
|
|
||||||
// Log warnings and throw errors
|
// Log warnings and throw errors
|
||||||
if (warnings.length > 0) {
|
if (warnings.length > 0) {
|
||||||
console.warn('⚠️ Configuration warnings:', warnings);
|
logger.warn('Configuration warnings', 'CONFIG', warnings);
|
||||||
}
|
}
|
||||||
if (errors.length > 0) {
|
if (errors.length > 0) {
|
||||||
console.error('❌ Configuration errors:', errors);
|
logger.error('Configuration errors', 'CONFIG', errors);
|
||||||
throw new Error(`Configuration validation failed: ${errors.join(', ')}`);
|
throw new Error(`Configuration validation failed: ${errors.join(', ')}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -956,7 +958,7 @@ export function exportAsEnvVars(
|
|||||||
*/
|
*/
|
||||||
export function logConfig(): void {
|
export function logConfig(): void {
|
||||||
if (unifiedConfig.features.debugMode) {
|
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,
|
environment: unifiedConfig.app.environment,
|
||||||
app: unifiedConfig.app.name,
|
app: unifiedConfig.app.name,
|
||||||
version: unifiedConfig.app.version,
|
version: unifiedConfig.app.version,
|
||||||
|
|||||||
+43
-25
@@ -8,8 +8,12 @@ import React, {
|
|||||||
import { User } from '../types';
|
import { User } from '../types';
|
||||||
import { databaseService } from '../services/database';
|
import { databaseService } from '../services/database';
|
||||||
import { authService } from '../services/auth/auth.service';
|
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 SESSION_KEY = 'medication_app_session';
|
||||||
|
const AUTH_CONTEXT = 'USER_CONTEXT';
|
||||||
|
|
||||||
interface UserContextType {
|
interface UserContextType {
|
||||||
user: User | null;
|
user: User | null;
|
||||||
@@ -68,25 +72,23 @@ export const UserProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
// Use auth service for password-based login
|
// Use auth service for password-based login
|
||||||
const result = await authService.login({ email, password });
|
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
|
// Update last login time
|
||||||
const updatedUser = { ...result.user, lastLoginAt: new Date() };
|
const updatedUser = { ...result.user, lastLoginAt: new Date() };
|
||||||
await databaseService.updateUser(updatedUser);
|
await databaseService.updateUser(updatedUser);
|
||||||
|
|
||||||
console.warn('Updated user with last login:', updatedUser);
|
tokenStorage.save({
|
||||||
|
accessToken: result.accessToken,
|
||||||
// Store access token for subsequent API calls.
|
refreshToken: result.refreshToken,
|
||||||
localStorage.setItem('access_token', result.accessToken);
|
});
|
||||||
// Set the user from the login result
|
// Set the user from the login result
|
||||||
setUser(updatedUser);
|
setUser(updatedUser);
|
||||||
|
logger.auth.login('User authenticated with email/password', {
|
||||||
console.warn('User set in context');
|
userId: updatedUser._id,
|
||||||
|
email: updatedUser.email,
|
||||||
|
});
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Login error:', error);
|
logger.auth.error('Login error', normalizeError(error), { email });
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -101,7 +103,10 @@ export const UserProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
// Don't auto-login after registration, require email verification
|
// Don't auto-login after registration, require email verification
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Registration error:', error);
|
logger.auth.error('Registration error', normalizeError(error), {
|
||||||
|
email,
|
||||||
|
username,
|
||||||
|
});
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -113,23 +118,26 @@ export const UserProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
try {
|
try {
|
||||||
const result = await authService.loginWithOAuth(provider, userData);
|
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
|
// Update last login time
|
||||||
const updatedUser = { ...result.user, lastLoginAt: new Date() };
|
const updatedUser = { ...result.user, lastLoginAt: new Date() };
|
||||||
await databaseService.updateUser(updatedUser);
|
await databaseService.updateUser(updatedUser);
|
||||||
|
|
||||||
console.warn('Updated OAuth user with last login:', updatedUser);
|
tokenStorage.save({
|
||||||
|
accessToken: result.accessToken,
|
||||||
localStorage.setItem('access_token', result.accessToken);
|
refreshToken: result.refreshToken,
|
||||||
|
});
|
||||||
setUser(updatedUser);
|
setUser(updatedUser);
|
||||||
|
logger.auth.login('User authenticated via OAuth', {
|
||||||
console.warn('OAuth user set in context');
|
userId: updatedUser._id,
|
||||||
|
provider,
|
||||||
|
email: updatedUser.email,
|
||||||
|
});
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('OAuth login error:', error);
|
logger.auth.error('OAuth login error', normalizeError(error), {
|
||||||
|
provider,
|
||||||
|
email: userData.email,
|
||||||
|
});
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -144,15 +152,21 @@ export const UserProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
await authService.changePassword(user._id, currentPassword, newPassword);
|
await authService.changePassword(user._id, currentPassword, newPassword);
|
||||||
|
logger.auth.login('User changed password', { userId: user._id });
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Password change error:', error);
|
logger.auth.error('Password change error', normalizeError(error), {
|
||||||
|
userId: user?._id,
|
||||||
|
});
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const logout = () => {
|
const logout = () => {
|
||||||
|
const currentUserId = user?._id;
|
||||||
|
tokenStorage.clear();
|
||||||
setUser(null);
|
setUser(null);
|
||||||
|
logger.auth.logout('User logged out', { userId: currentUserId });
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateUser = async (updatedUser: User) => {
|
const updateUser = async (updatedUser: User) => {
|
||||||
@@ -160,8 +174,12 @@ export const UserProvider: React.FC<{ children: ReactNode }> = ({
|
|||||||
const savedUser = await databaseService.updateUser(updatedUser);
|
const savedUser = await databaseService.updateUser(updatedUser);
|
||||||
setUser(savedUser);
|
setUser(savedUser);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to update user', error);
|
logger.error(
|
||||||
// Optionally revert state or show error
|
'Failed to update user profile',
|
||||||
|
AUTH_CONTEXT,
|
||||||
|
{ userId: updatedUser._id },
|
||||||
|
normalizeError(error)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -10,6 +10,16 @@ The rxminder application supports multiple ways to configure deployments using e
|
|||||||
2. **Dynamic Configuration**: Runtime environment variable injection
|
2. **Dynamic Configuration**: Runtime environment variable injection
|
||||||
3. **Hybrid Approach**: Combination of both methods
|
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
|
## Environment Variable Sources
|
||||||
|
|
||||||
Variables are loaded in the following priority order (last wins):
|
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_USERNAME` | `admin` | Database username |
|
||||||
| `COUCHDB_PASSWORD` | - | Database password (required) |
|
| `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
|
### Network & Ingress Variables
|
||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
@@ -203,6 +225,8 @@ DEV_API_URL=http://localhost:5984
|
|||||||
| `CERT_MANAGER_ISSUER` | `letsencrypt-prod` | Certificate issuer |
|
| `CERT_MANAGER_ISSUER` | `letsencrypt-prod` | Certificate issuer |
|
||||||
| `CORS_ORIGIN` | `*` | CORS allowed origins |
|
| `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
|
### Performance Variables
|
||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
@@ -243,6 +267,20 @@ DEV_API_URL=http://localhost:5984
|
|||||||
| `API_SECRET_KEY` | - | API secret key |
|
| `API_SECRET_KEY` | - | API secret key |
|
||||||
| `JWT_SECRET` | - | JWT signing secret |
|
| `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
|
## Usage Examples
|
||||||
|
|
||||||
### Basic Development Setup
|
### Basic Development Setup
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
"test:unit": "jest --testPathPatterns='(utils|types).*test\\.(ts|js)$'",
|
"test:unit": "jest --testPathPatterns='(utils|types).*test\\.(ts|js)$'",
|
||||||
"test:services": "jest --testPathPatterns='services.*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))$'",
|
"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": "markdownlint-cli2 \"**/*.md\"",
|
||||||
"lint:markdown:fix": "markdownlint-cli2 --fix \"**/*.md\"",
|
"lint:markdown:fix": "markdownlint-cli2 --fix \"**/*.md\"",
|
||||||
"check:secrets": "secretlint \"**/*\"",
|
"check:secrets": "secretlint \"**/*\"",
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -1,15 +1,17 @@
|
|||||||
// Mock the mailgun config before any imports
|
// Mock the mailgun config before any imports
|
||||||
const mockGetMailgunConfig = jest.fn().mockReturnValue({
|
jest.mock('../mailgun.config', () => {
|
||||||
|
const defaultConfig = {
|
||||||
apiKey: 'test-api-key',
|
apiKey: 'test-api-key',
|
||||||
domain: 'test.mailgun.org',
|
domain: 'test.mailgun.org',
|
||||||
baseUrl: 'https://api.mailgun.net/v3',
|
baseUrl: 'https://api.mailgun.net/v3',
|
||||||
fromName: 'Test App',
|
fromName: 'Test App',
|
||||||
fromEmail: 'test@example.com',
|
fromEmail: 'test@example.com',
|
||||||
});
|
};
|
||||||
|
|
||||||
jest.mock('../mailgun.config', () => ({
|
return {
|
||||||
getMailgunConfig: mockGetMailgunConfig,
|
getMailgunConfig: jest.fn(() => defaultConfig),
|
||||||
}));
|
};
|
||||||
|
});
|
||||||
|
|
||||||
// Mock the app config
|
// Mock the app config
|
||||||
jest.mock('../../config/unified.config', () => ({
|
jest.mock('../../config/unified.config', () => ({
|
||||||
@@ -18,6 +20,7 @@ jest.mock('../../config/unified.config', () => ({
|
|||||||
baseUrl: 'http://localhost:3000',
|
baseUrl: 'http://localhost:3000',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
getAppConfig: jest.fn(() => ({ baseUrl: 'http://localhost:3000' })),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock global fetch and related APIs
|
// Mock global fetch and related APIs
|
||||||
@@ -32,10 +35,28 @@ global.btoa = jest
|
|||||||
|
|
||||||
// Import the service after mocks are set up
|
// Import the service after mocks are set up
|
||||||
import { MailgunService } from '../mailgun.service';
|
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', () => {
|
describe('MailgunService', () => {
|
||||||
let mockFetch: jest.MockedFunction<typeof fetch>;
|
let mockFetch: jest.MockedFunction<typeof fetch>;
|
||||||
let mockFormData: jest.MockedFunction<any>;
|
let mockFormData: jest.MockedFunction<any>;
|
||||||
|
let warnSpy: jest.SpyInstance;
|
||||||
|
let infoSpy: jest.SpyInstance;
|
||||||
|
let errorSpy: jest.SpyInstance;
|
||||||
|
let debugSpy: jest.SpyInstance;
|
||||||
|
|
||||||
const mockConfig = {
|
const mockConfig = {
|
||||||
apiKey: 'test-api-key',
|
apiKey: 'test-api-key',
|
||||||
@@ -47,10 +68,13 @@ describe('MailgunService', () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
console.warn = jest.fn();
|
|
||||||
console.error = jest.fn();
|
|
||||||
mockFetch = fetch as jest.MockedFunction<typeof fetch>;
|
mockFetch = fetch as jest.MockedFunction<typeof fetch>;
|
||||||
mockFormData = MockFormData;
|
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', () => {
|
describe('constructor', () => {
|
||||||
@@ -67,11 +91,28 @@ describe('MailgunService', () => {
|
|||||||
|
|
||||||
new MailgunService();
|
new MailgunService();
|
||||||
|
|
||||||
expect(console.warn).toHaveBeenCalledWith(
|
expect(warnSpy).toHaveBeenCalledWith(
|
||||||
'📧 Mailgun Service: Running in development mode (emails will be logged only)'
|
'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(
|
expect(infoSpy).toHaveBeenCalledWith(
|
||||||
'💡 To enable real emails, configure Mailgun credentials in .env.local'
|
'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();
|
new MailgunService();
|
||||||
|
|
||||||
expect(console.warn).toHaveBeenCalledWith(
|
expect(infoSpy).toHaveBeenCalledWith(
|
||||||
'📧 Mailgun Service: Configured for production with domain:',
|
'Mailgun configured for delivery',
|
||||||
'test.mailgun.org'
|
'MAILGUN',
|
||||||
|
{
|
||||||
|
domain: 'test.mailgun.org',
|
||||||
|
fromEmail: 'test@example.com',
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
expect(warnSpy).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -122,8 +168,9 @@ describe('MailgunService', () => {
|
|||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
expect(console.warn).toHaveBeenCalledWith(
|
expect(infoSpy).toHaveBeenCalledWith(
|
||||||
'📧 Email sent successfully via Mailgun:',
|
'Email sent via Mailgun',
|
||||||
|
'MAILGUN',
|
||||||
{
|
{
|
||||||
to: 'test@example.com',
|
to: 'test@example.com',
|
||||||
subject: 'Test Subject',
|
subject: 'Test Subject',
|
||||||
@@ -148,8 +195,10 @@ describe('MailgunService', () => {
|
|||||||
const result = await service.sendEmail('test@example.com', template);
|
const result = await service.sendEmail('test@example.com', template);
|
||||||
|
|
||||||
expect(result).toBe(false);
|
expect(result).toBe(false);
|
||||||
expect(console.error).toHaveBeenCalledWith(
|
expect(errorSpy).toHaveBeenCalledWith(
|
||||||
'Email sending failed:',
|
'Mailgun email send failed',
|
||||||
|
'MAILGUN',
|
||||||
|
{ domain: 'test.mailgun.org' },
|
||||||
expect.any(Error)
|
expect.any(Error)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -165,8 +214,10 @@ describe('MailgunService', () => {
|
|||||||
const result = await service.sendEmail('test@example.com', template);
|
const result = await service.sendEmail('test@example.com', template);
|
||||||
|
|
||||||
expect(result).toBe(false);
|
expect(result).toBe(false);
|
||||||
expect(console.error).toHaveBeenCalledWith(
|
expect(errorSpy).toHaveBeenCalledWith(
|
||||||
'Email sending failed:',
|
'Mailgun email send failed',
|
||||||
|
'MAILGUN',
|
||||||
|
{ domain: 'test.mailgun.org' },
|
||||||
expect.any(Error)
|
expect.any(Error)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -237,6 +288,56 @@ describe('MailgunService', () => {
|
|||||||
expect.anything()
|
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', () => {
|
describe('sendVerificationEmail', () => {
|
||||||
@@ -360,6 +461,7 @@ describe('MailgunService', () => {
|
|||||||
mode: 'production',
|
mode: 'production',
|
||||||
domain: 'test.mailgun.org',
|
domain: 'test.mailgun.org',
|
||||||
fromEmail: 'test@example.com',
|
fromEmail: 'test@example.com',
|
||||||
|
missingFields: [],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -382,6 +484,11 @@ describe('MailgunService', () => {
|
|||||||
mode: 'development',
|
mode: 'development',
|
||||||
domain: undefined,
|
domain: undefined,
|
||||||
fromEmail: undefined,
|
fromEmail: undefined,
|
||||||
|
missingFields: [
|
||||||
|
'VITE_MAILGUN_API_KEY',
|
||||||
|
'VITE_MAILGUN_DOMAIN',
|
||||||
|
'VITE_MAILGUN_FROM_EMAIL',
|
||||||
|
],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -404,6 +511,13 @@ describe('MailgunService', () => {
|
|||||||
mode: 'development',
|
mode: 'development',
|
||||||
domain: '',
|
domain: '',
|
||||||
fromEmail: '',
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,6 +4,7 @@ import { mailgunService } from '../mailgun.service';
|
|||||||
import { AccountStatus } from './auth.constants';
|
import { AccountStatus } from './auth.constants';
|
||||||
import { databaseService } from '../database';
|
import { databaseService } from '../database';
|
||||||
import { tokenService } from './token.service';
|
import { tokenService } from './token.service';
|
||||||
|
import { logger } from '../logging';
|
||||||
|
|
||||||
const TOKEN_EXPIRY_HOURS = 24;
|
const TOKEN_EXPIRY_HOURS = 24;
|
||||||
|
|
||||||
@@ -32,7 +33,7 @@ export class EmailVerificationService {
|
|||||||
token
|
token
|
||||||
);
|
);
|
||||||
if (!emailSent) {
|
if (!emailSent) {
|
||||||
console.warn('Failed to send verification email');
|
logger.auth.warn('Failed to send verification email');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { EmailVerificationToken } from './auth.types';
|
|||||||
import type { CouchDBDocument } from '../../types';
|
import type { CouchDBDocument } from '../../types';
|
||||||
import { getDatabaseConfig } from '../../config/unified.config';
|
import { getDatabaseConfig } from '../../config/unified.config';
|
||||||
import { logger } from '../logging';
|
import { logger } from '../logging';
|
||||||
|
import { encodeBase64 } from '../../utils/base64';
|
||||||
|
|
||||||
export interface PasswordResetToken {
|
export interface PasswordResetToken {
|
||||||
userId: string;
|
userId: string;
|
||||||
@@ -42,14 +43,8 @@ function fromISO(date: string | Date): Date {
|
|||||||
return date instanceof Date ? date : new Date(date);
|
return date instanceof Date ? date : new Date(date);
|
||||||
}
|
}
|
||||||
|
|
||||||
function base64Auth(user: string, pass: string): string {
|
const base64Auth = (user: string, pass: string): string =>
|
||||||
// btoa may not exist in some environments (e.g., Node). Fallback to Buffer.
|
encodeBase64(`${user}:${pass}`);
|
||||||
if (typeof btoa !== 'undefined') {
|
|
||||||
return btoa(`${user}:${pass}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Buffer.from(`${user}:${pass}`).toString('base64');
|
|
||||||
}
|
|
||||||
|
|
||||||
export class TokenService {
|
export class TokenService {
|
||||||
private couchBaseUrl: string | null = null;
|
private couchBaseUrl: string | null = null;
|
||||||
|
|||||||
+34
-22
@@ -1,32 +1,36 @@
|
|||||||
import { databaseService } from './database';
|
import { databaseService } from './database';
|
||||||
import { AccountStatus } from './auth/auth.constants';
|
import { AccountStatus } from './auth/auth.constants';
|
||||||
import { UserRole } from '../types';
|
import { UserRole } from '../types';
|
||||||
|
import { hashPassword, isBcryptHash } from './auth/password.service';
|
||||||
|
import { logger } from './logging';
|
||||||
|
|
||||||
export class DatabaseSeeder {
|
export class DatabaseSeeder {
|
||||||
private static seedingInProgress = false;
|
private static seedingInProgress = false;
|
||||||
private static seedingCompleted = false;
|
private static seedingCompleted = false;
|
||||||
|
|
||||||
async seedDefaultAdmin(): Promise<void> {
|
async seedDefaultAdmin(): Promise<void> {
|
||||||
const adminEmail = 'admin@localhost';
|
const adminEmail =
|
||||||
const adminPassword = 'admin123!';
|
(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...');
|
logger.db.info('🌱 Starting admin user seeding...');
|
||||||
console.warn('📧 Admin email:', adminEmail);
|
logger.db.info('📧 Admin email:', adminEmail);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Check if admin already exists
|
// Check if admin already exists
|
||||||
const existingAdmin = await databaseService.findUserByEmail(adminEmail);
|
const existingAdmin = await databaseService.findUserByEmail(adminEmail);
|
||||||
|
|
||||||
if (existingAdmin) {
|
if (existingAdmin) {
|
||||||
console.warn('✅ Default admin user already exists');
|
logger.db.info('✅ Default admin user already exists');
|
||||||
console.warn('👤 Existing admin:', existingAdmin);
|
logger.db.info('👤 Existing admin:', existingAdmin);
|
||||||
|
|
||||||
// Check if admin needs to be updated to correct role/status
|
// Check if admin needs to be updated to correct role/status
|
||||||
if (
|
if (
|
||||||
existingAdmin.role !== UserRole.ADMIN ||
|
existingAdmin.role !== UserRole.ADMIN ||
|
||||||
existingAdmin.status !== AccountStatus.ACTIVE
|
existingAdmin.status !== AccountStatus.ACTIVE
|
||||||
) {
|
) {
|
||||||
console.warn('🔧 Updating admin user role and status...');
|
logger.db.info('🔧 Updating admin user role and status...');
|
||||||
const updatedAdmin = {
|
const updatedAdmin = {
|
||||||
...existingAdmin,
|
...existingAdmin,
|
||||||
role: UserRole.ADMIN,
|
role: UserRole.ADMIN,
|
||||||
@@ -34,21 +38,25 @@ export class DatabaseSeeder {
|
|||||||
emailVerified: true,
|
emailVerified: true,
|
||||||
};
|
};
|
||||||
await databaseService.updateUser(updatedAdmin);
|
await databaseService.updateUser(updatedAdmin);
|
||||||
console.warn('✅ Admin user updated successfully');
|
logger.db.info('✅ Admin user updated successfully');
|
||||||
console.warn('👤 Updated admin:', updatedAdmin);
|
logger.db.info('👤 Updated admin:', updatedAdmin);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.warn('🚀 Creating new admin user...');
|
logger.db.info('🚀 Creating new admin user...');
|
||||||
// Create default admin user
|
// Create default admin user
|
||||||
|
const passwordToUse = isBcryptHash(adminPassword)
|
||||||
|
? adminPassword
|
||||||
|
: await hashPassword(adminPassword);
|
||||||
|
|
||||||
const adminUser = await databaseService.createUserWithPassword(
|
const adminUser = await databaseService.createUserWithPassword(
|
||||||
adminEmail,
|
adminEmail,
|
||||||
adminPassword,
|
passwordToUse,
|
||||||
'admin'
|
'admin'
|
||||||
);
|
);
|
||||||
|
|
||||||
console.warn('👤 Admin user created:', adminUser);
|
logger.db.info('👤 Admin user created:', adminUser);
|
||||||
|
|
||||||
// Update user to admin role and active status
|
// Update user to admin role and active status
|
||||||
const updatedAdmin = {
|
const updatedAdmin = {
|
||||||
@@ -62,13 +70,15 @@ export class DatabaseSeeder {
|
|||||||
|
|
||||||
await databaseService.updateUser(updatedAdmin);
|
await databaseService.updateUser(updatedAdmin);
|
||||||
|
|
||||||
console.warn('✅ Admin user created successfully');
|
logger.db.info('✅ Admin user created successfully');
|
||||||
console.warn('👤 Final admin user:', updatedAdmin);
|
logger.db.info('👤 Final admin user:', updatedAdmin);
|
||||||
console.warn('📧 Email:', adminEmail);
|
logger.db.info('📧 Email:', adminEmail);
|
||||||
console.warn('🔑 Password:', adminPassword);
|
logger.db.info('🔑 Password:', adminPassword);
|
||||||
console.warn('⚠️ Please change the default password after first login!');
|
logger.db.info(
|
||||||
|
'⚠️ Please change the default password after first login!'
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Failed to create default admin user:', error);
|
logger.db.error('❌ Failed to create default admin user:', error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -76,19 +86,21 @@ export class DatabaseSeeder {
|
|||||||
async seedDatabase(): Promise<void> {
|
async seedDatabase(): Promise<void> {
|
||||||
// Prevent multiple seeding attempts
|
// Prevent multiple seeding attempts
|
||||||
if (DatabaseSeeder.seedingInProgress || DatabaseSeeder.seedingCompleted) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
DatabaseSeeder.seedingInProgress = true;
|
DatabaseSeeder.seedingInProgress = true;
|
||||||
console.warn('🌱 Starting database seeding...');
|
logger.db.info('🌱 Starting database seeding...');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await this.seedDefaultAdmin();
|
await this.seedDefaultAdmin();
|
||||||
DatabaseSeeder.seedingCompleted = true;
|
DatabaseSeeder.seedingCompleted = true;
|
||||||
console.warn('🎯 Admin seeding completed successfully');
|
logger.db.info('🎯 Admin seeding completed successfully');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('💥 Database seeding failed:', error);
|
logger.db.error('💥 Database seeding failed:', error);
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
DatabaseSeeder.seedingInProgress = false;
|
DatabaseSeeder.seedingInProgress = false;
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { MockDatabaseStrategy } from './MockDatabaseStrategy';
|
|||||||
import { ProductionDatabaseStrategy } from './ProductionDatabaseStrategy';
|
import { ProductionDatabaseStrategy } from './ProductionDatabaseStrategy';
|
||||||
import { DatabaseStrategy } from './types';
|
import { DatabaseStrategy } from './types';
|
||||||
import { AccountStatus } from '../auth/auth.constants';
|
import { AccountStatus } from '../auth/auth.constants';
|
||||||
|
import { hashPassword } from '../auth/password.service';
|
||||||
|
import { logger } from '../logging';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Consolidated Database Service
|
* Consolidated Database Service
|
||||||
@@ -30,9 +32,9 @@ export class DatabaseService implements DatabaseStrategy {
|
|||||||
try {
|
try {
|
||||||
return new ProductionDatabaseStrategy();
|
return new ProductionDatabaseStrategy();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(
|
logger.db.warn(
|
||||||
'Production CouchDB service not available, falling back to mock:',
|
'Production CouchDB service not available, falling back to mock',
|
||||||
error
|
error as Error
|
||||||
);
|
);
|
||||||
return new MockDatabaseStrategy();
|
return new MockDatabaseStrategy();
|
||||||
}
|
}
|
||||||
@@ -188,9 +190,10 @@ export class DatabaseService implements DatabaseStrategy {
|
|||||||
async changeUserPassword(userId: string, newPassword: string) {
|
async changeUserPassword(userId: string, newPassword: string) {
|
||||||
const user = await this.strategy.getUserById(userId);
|
const user = await this.strategy.getUserById(userId);
|
||||||
if (!user) throw new Error('User not found');
|
if (!user) throw new Error('User not found');
|
||||||
|
const hashedPassword = await hashPassword(newPassword);
|
||||||
return this.strategy.updateUser({
|
return this.strategy.updateUser({
|
||||||
...user,
|
...user,
|
||||||
password: newPassword,
|
password: hashedPassword,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { AccountStatus } from '../auth/auth.constants';
|
|||||||
import { DatabaseStrategy, DatabaseError } from './types';
|
import { DatabaseStrategy, DatabaseError } from './types';
|
||||||
import { getDatabaseConfig } from '../../config/unified.config';
|
import { getDatabaseConfig } from '../../config/unified.config';
|
||||||
import { logger } from '../logging';
|
import { logger } from '../logging';
|
||||||
|
import { encodeBase64 } from '../../utils/base64';
|
||||||
|
|
||||||
export class ProductionDatabaseStrategy implements DatabaseStrategy {
|
export class ProductionDatabaseStrategy implements DatabaseStrategy {
|
||||||
private baseUrl: string;
|
private baseUrl: string;
|
||||||
@@ -22,7 +23,7 @@ export class ProductionDatabaseStrategy implements DatabaseStrategy {
|
|||||||
const dbConfig = getDatabaseConfig();
|
const dbConfig = getDatabaseConfig();
|
||||||
|
|
||||||
this.baseUrl = dbConfig.url;
|
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', {
|
logger.db.query('Initializing production database strategy', {
|
||||||
url: dbConfig.url,
|
url: dbConfig.url,
|
||||||
@@ -47,6 +48,9 @@ export class ProductionDatabaseStrategy implements DatabaseStrategy {
|
|||||||
for (const dbName of databases) {
|
for (const dbName of databases) {
|
||||||
try {
|
try {
|
||||||
await this.createDatabaseIfNotExists(dbName);
|
await this.createDatabaseIfNotExists(dbName);
|
||||||
|
if (dbName === 'users') {
|
||||||
|
await this.ensureUserEmailIndex();
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.db.error(
|
logger.db.error(
|
||||||
`Failed to initialize database ${dbName}`,
|
`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> {
|
private async createDatabaseIfNotExists(dbName: string): Promise<void> {
|
||||||
try {
|
try {
|
||||||
// Check if database exists
|
// Check if database exists
|
||||||
@@ -150,8 +188,10 @@ export class ProductionDatabaseStrategy implements DatabaseStrategy {
|
|||||||
|
|
||||||
private async putDoc<T extends CouchDBDocument>(
|
private async putDoc<T extends CouchDBDocument>(
|
||||||
dbName: string,
|
dbName: string,
|
||||||
doc: T
|
doc: T,
|
||||||
|
retries = 2
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
|
try {
|
||||||
const response = await this.makeRequest<{ id: string; rev: string }>(
|
const response = await this.makeRequest<{ id: string; rev: string }>(
|
||||||
'PUT',
|
'PUT',
|
||||||
`/${dbName}/${doc._id}`,
|
`/${dbName}/${doc._id}`,
|
||||||
@@ -162,6 +202,32 @@ export class ProductionDatabaseStrategy implements DatabaseStrategy {
|
|||||||
...doc,
|
...doc,
|
||||||
_rev: response.rev,
|
_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(
|
private async deleteDoc(
|
||||||
@@ -225,13 +291,14 @@ export class ProductionDatabaseStrategy implements DatabaseStrategy {
|
|||||||
|
|
||||||
async findUserByEmail(email: string): Promise<User | null> {
|
async findUserByEmail(email: string): Promise<User | null> {
|
||||||
const response = await this.makeRequest<{
|
const response = await this.makeRequest<{
|
||||||
rows: Array<{ doc: User }>;
|
docs: User[];
|
||||||
|
warning?: string;
|
||||||
}>('POST', '/users/_find', {
|
}>('POST', '/users/_find', {
|
||||||
selector: { email },
|
selector: { email },
|
||||||
limit: 1,
|
limit: 1,
|
||||||
});
|
});
|
||||||
|
|
||||||
return response.rows[0]?.doc || null;
|
return response.docs[0] || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteUser(id: string): Promise<boolean> {
|
async deleteUser(id: string): Promise<boolean> {
|
||||||
|
|||||||
+6
-3
@@ -1,6 +1,8 @@
|
|||||||
/**
|
/**
|
||||||
* Mock email service for sending verification emails
|
* Mock email service for sending verification emails
|
||||||
*/
|
*/
|
||||||
|
import { logger } from './logging';
|
||||||
|
|
||||||
export class EmailService {
|
export class EmailService {
|
||||||
/**
|
/**
|
||||||
* Simulates sending a verification email with a link to /verify-email?token=${token}
|
* 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> {
|
async sendVerificationEmail(email: string, token: string): Promise<void> {
|
||||||
// In a real implementation, this would send an actual email
|
// In a real implementation, this would send an actual email
|
||||||
// For this demo, we'll just log the action
|
// For this demo, we'll just log the action
|
||||||
console.warn(
|
logger.info(
|
||||||
`📧 Sending verification email to ${email} with token: ${token}`
|
`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
|
// Simulate network delay
|
||||||
await new Promise(resolve => setTimeout(resolve, 500));
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
|||||||
+83
-18
@@ -5,6 +5,9 @@
|
|||||||
|
|
||||||
import { getMailgunConfig, type MailgunConfig } from './mailgun.config';
|
import { getMailgunConfig, type MailgunConfig } from './mailgun.config';
|
||||||
import { getAppConfig } from '../config/unified.config';
|
import { getAppConfig } from '../config/unified.config';
|
||||||
|
import { logger } from './logging';
|
||||||
|
import { normalizeError } from '../utils/error';
|
||||||
|
import { encodeBase64 } from '../utils/base64';
|
||||||
|
|
||||||
interface EmailTemplate {
|
interface EmailTemplate {
|
||||||
subject: string;
|
subject: string;
|
||||||
@@ -14,24 +17,34 @@ interface EmailTemplate {
|
|||||||
|
|
||||||
export class MailgunService {
|
export class MailgunService {
|
||||||
private config: MailgunConfig;
|
private config: MailgunConfig;
|
||||||
|
private readonly context = 'MAILGUN';
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.config = getMailgunConfig();
|
this.config = getMailgunConfig();
|
||||||
|
|
||||||
// Log configuration status on startup
|
// Log configuration status on startup
|
||||||
const status = this.getConfigurationStatus();
|
const status = this.getConfigurationStatus();
|
||||||
if (status.mode === 'development') {
|
if (!status.configured) {
|
||||||
console.warn(
|
logger.warn(
|
||||||
'📧 Mailgun Service: Running in development mode (emails will be logged only)'
|
'Mailgun running in development mode; emails will not be delivered',
|
||||||
|
this.context,
|
||||||
|
{
|
||||||
|
missingFields: status.missingFields,
|
||||||
|
domain: status.domain,
|
||||||
|
}
|
||||||
);
|
);
|
||||||
console.warn(
|
logger.info(
|
||||||
'💡 To enable real emails, configure Mailgun credentials in .env.local'
|
'To enable email delivery, configure Mailgun environment variables',
|
||||||
|
this.context,
|
||||||
|
{
|
||||||
|
requiredVariables: status.missingFields,
|
||||||
|
}
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
console.warn(
|
logger.info('Mailgun configured for delivery', this.context, {
|
||||||
'📧 Mailgun Service: Configured for production with domain:',
|
domain: status.domain,
|
||||||
status.domain
|
fromEmail: status.fromEmail,
|
||||||
);
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,6 +108,27 @@ export class MailgunService {
|
|||||||
|
|
||||||
async sendEmail(to: string, template: EmailTemplate): Promise<boolean> {
|
async sendEmail(to: string, template: EmailTemplate): Promise<boolean> {
|
||||||
try {
|
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
|
// Production Mailgun API call
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append(
|
formData.append(
|
||||||
@@ -113,7 +147,7 @@ export class MailgunService {
|
|||||||
{
|
{
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: `Basic ${btoa(`api:${this.config.apiKey}`)}`,
|
Authorization: `Basic ${encodeBase64(`api:${this.config.apiKey}`)}`,
|
||||||
},
|
},
|
||||||
body: formData,
|
body: formData,
|
||||||
}
|
}
|
||||||
@@ -125,7 +159,7 @@ export class MailgunService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const result = await response.json();
|
const result = await response.json();
|
||||||
console.warn('📧 Email sent successfully via Mailgun:', {
|
logger.info('Email sent via Mailgun', this.context, {
|
||||||
to,
|
to,
|
||||||
subject: template.subject,
|
subject: template.subject,
|
||||||
messageId: result.id,
|
messageId: result.id,
|
||||||
@@ -133,7 +167,14 @@ export class MailgunService {
|
|||||||
|
|
||||||
return true;
|
return true;
|
||||||
} catch (error: unknown) {
|
} 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;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -156,13 +197,10 @@ export class MailgunService {
|
|||||||
mode: 'development' | 'production';
|
mode: 'development' | 'production';
|
||||||
domain: string;
|
domain: string;
|
||||||
fromEmail: string;
|
fromEmail: string;
|
||||||
|
missingFields: string[];
|
||||||
} {
|
} {
|
||||||
const configured =
|
const missingFields = this.getMissingFields();
|
||||||
!!this.config.apiKey &&
|
const configured = missingFields.length === 0;
|
||||||
!!this.config.domain &&
|
|
||||||
!!this.config.baseUrl &&
|
|
||||||
!!this.config.fromEmail &&
|
|
||||||
!!this.config.fromName;
|
|
||||||
const mode: 'development' | 'production' = configured
|
const mode: 'development' | 'production' = configured
|
||||||
? 'production'
|
? 'production'
|
||||||
: 'development';
|
: 'development';
|
||||||
@@ -171,8 +209,35 @@ export class MailgunService {
|
|||||||
mode,
|
mode,
|
||||||
domain: this.config.domain,
|
domain: this.config.domain,
|
||||||
fromEmail: this.config.fromEmail,
|
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();
|
export const mailgunService = new MailgunService();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -99,6 +99,74 @@ describe('Schedule Utilities', () => {
|
|||||||
const schedule = generateSchedule([medication], baseDate);
|
const schedule = generateSchedule([medication], baseDate);
|
||||||
expect(schedule).toEqual([]);
|
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', () => {
|
describe('generateReminderSchedule', () => {
|
||||||
@@ -186,6 +254,50 @@ describe('Schedule Utilities', () => {
|
|||||||
const uniqueIds = new Set(ids);
|
const uniqueIds = new Set(ids);
|
||||||
expect(uniqueIds.size).toBe(ids.length);
|
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', () => {
|
describe('error handling', () => {
|
||||||
|
|||||||
@@ -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'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -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
@@ -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;
|
||||||
Reference in New Issue
Block a user