Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -215,8 +215,16 @@ Design note: kept separate from `Prescription`/`LabOrder` so a facility's operat
### 4.8 Notifications
```
Notification
id, user_id, type, payload (jsonb), read_at (nullable), created_at
id, user_id (FK -> User), type (typed catalogue), payload (jsonb),
channel (in_app|email|sms), read_at (nullable), created_at
```
The in-app inbox is owner-scoped and keyset-paginated by `(created_at, id)`.
Payload schemas contain opaque entity identifiers only; clinical details,
intake, consultation notes, and results must never be copied into a notification
payload. `notify()` persists the in-app row before calling registered channel
adapters, and adapter failures are isolated from that write. Email/SMS providers
remain undecided until issue #17; adapters are registered behind the module's
service interface.

### 4.9 Auth hardening state (Phase 8)
```
Expand Down
1 change: 1 addition & 0 deletions ARCHITECTURE_ESSENTIALS.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ Quick-reference only. Full reasoning lives in `ARCHITECTURE.md`. Full product sc
`Consultation` → `Prescription`(+`PrescriptionItem`) / `LabOrder`(+`LabResult`)
`FulfillmentRecord` (facility-owned) references an order but doesn't mutate it.
`User` → `EmailVerificationToken` (Phase 8, issue #34 — hashed/expiring, single-use; `User.emailVerifiedAt` gates booking + payment mutations, never login/browsing).
`User` → `Notification` (Phase 6, issue #93 — typed, owner-scoped in-app inbox; payloads contain identifiers, not clinical detail).
`LoginAttempt` (Phase 8, issue #35 — Postgres-backed login throttle buckets, one per normalised email and per client IP; counters move only via a conditional upsert).
`PatientProfile` → `PatientExport` → `PatientExportChunk` (Phase 8, issue #40 — right-of-access export jobs + their chunked JSON bundle; each module contributes a section via its own service, registered in `src/export-sections.ts` — see ARCHITECTURE.md §3.1).
`FacilityProfile` → `WalletLink` (payout address) ; `PatientProfile`/`FacilityProfile` → `PaymentIntent` (opaque `orderType`/`orderId` — see docs/adr/0004)
Expand Down
7 changes: 5 additions & 2 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,10 +92,13 @@ for status and issue links.
- [ ] Doc reconciliation: PRD §8.1.6 (facility fulfillment integrity) — not marked resolved; this phase proves the payment *mechanism* works, not the fulfillment-integrity question, which still needs real `FulfillmentRecord`/facility-matching (Phase 5) to actually address.

## Phase 6 — Notifications
**Status: Not started. Provider decision scoped into issue #17; trigger-point build-out blocked by Phases 3 & 4 and deliberately left unscoped until those land.**
- [ ] `Notification` model + dispatch interface (provider-agnostic, per ARCHITECTURE.md §2) — part of the trigger-point work, still blocked.
**Status: In progress. The owner-scoped in-app inbox and provider-agnostic dispatch interface are implemented; booking outbox delivery is tracked in #94. Email/SMS provider work remains blocked on issue #17's provider decision.**
- [x] `Notification` model + dispatch interface (provider-agnostic, per ARCHITECTURE.md §2), plus authenticated in-app inbox API — issue #93.
- [ ] Trigger points per PRD FR-15/FR-16 (booking confirmed, referral created/accepted/declined, order ready, results available, new booking, lab results returned) — still blocked by Phases 3/4.
- [ ] Transactional outbox for domain events, starting with booking confirmed — [issue #94](https://github.com/DocForum/docforum-core/issues/94).
- [ ] Pick and integrate a real provider (email/SMS) behind the interface — provider choice is currently undecided (ARCHITECTURE.md §8). Tracked as [issue #17](https://github.com/DocForum/docforum-core/issues/17) (Trivial, 100 pts) — a decision task, unblocked now.
- [ ] Email adapter, templates, and signed bounce/complaint webhook — [issue #95](https://github.com/DocForum/docforum-core/issues/95); depends on #17 and #93.
- [ ] SMS adapter, delivery callbacks, consent/opt-out, and send cap — [issue #96](https://github.com/DocForum/docforum-core/issues/96); depends on #17 and #93.

## Phase 7 — Frontend: core flows end-to-end
**Status: Superseded.** This phase predates the 3-repo org split (see the org note at the top of this file) — all frontend work described below is actually tracked in `docforum-web`'s own `ROADMAP.md` (Phases W2–W5, issues opened there). Left in place as a historical record rather than deleted, but no issues are or will be opened against it here.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
-- CreateEnum
CREATE TYPE "NotificationChannel" AS ENUM ('in_app', 'email', 'sms');

-- AlterTable
ALTER TABLE "Notification" ADD COLUMN "channel" "NotificationChannel" NOT NULL DEFAULT 'in_app',
ADD COLUMN "userId" TEXT NOT NULL;

-- CreateIndex
CREATE INDEX "Notification_userId_createdAt_id_idx" ON "Notification"("userId", "createdAt", "id");

-- CreateIndex
CREATE INDEX "Notification_userId_readAt_createdAt_id_idx" ON "Notification"("userId", "readAt", "createdAt", "id");

-- AddForeignKey
ALTER TABLE "Notification" ADD CONSTRAINT "Notification_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

19 changes: 16 additions & 3 deletions backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ enum Role {
admin
}

enum NotificationChannel {
in_app
email
sms
}

enum VerificationStatus {
pending
verified
Expand Down Expand Up @@ -129,6 +135,7 @@ model User {

emailVerificationTokens EmailVerificationToken[]
adminInvitesSent AdminInvite[]
notifications Notification[]

@@index([role])
}
Expand Down Expand Up @@ -469,12 +476,18 @@ model FulfillmentRecord {
}

model Notification {
id String @id @default(uuid())
id String @id @default(uuid())
userId String
type String
payload Json
channel NotificationChannel @default(in_app)
readAt DateTime?
createdAt DateTime @default(now())
// TODO: userId
createdAt DateTime @default(now())

user User @relation(fields: [userId], references: [id], onDelete: Cascade)

@@index([userId, createdAt, id])
@@index([userId, readAt, createdAt, id])
}

// --- Stellar payments (docforum-org split) -------------------------------
Expand Down
2 changes: 2 additions & 0 deletions backend/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { adminRouter } from './modules/users/routes';
import { registerPatientExportSections } from './export-sections';
import { auditRouter } from './modules/audit/routes';
import { requestId } from './middleware/request-id';
import { notificationsRouter } from './modules/notifications/routes';

export function createApp() {
const app = express();
Expand Down Expand Up @@ -46,6 +47,7 @@ export function createApp() {
// Phase 8, issue #39 — admin account lifecycle (invites, deactivate/
// reactivate) alongside the audit read path. Different paths, no overlap.
app.use('/admin', adminRouter);
app.use('/notifications', notificationsRouter);
app.use('/admin', auditRouter);

// Must be registered last — Express only routes errors here once every
Expand Down
43 changes: 41 additions & 2 deletions backend/src/modules/notifications/controllers/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,41 @@
// notifications controllers — placeholder.
export {};
import type { Request, Response } from 'express';
import { z } from 'zod';
import { HttpError } from '../../../http-error';
import { getParam } from '../../../request-params';
import * as notificationsService from '../services';

const listQuerySchema = z.object({
unread: z.enum(['true', 'false']).default('false'),
limit: z.coerce.number().int().min(1).max(100).default(50),
cursor: z.string().min(1).max(256).optional(),
}).strict();

function getUserId(req: Request): string {
if (!req.user) throw new HttpError(401, 'Authentication required.');
return req.user.id;
}

export async function list(req: Request, res: Response) {
const query = listQuerySchema.parse(req.query);
const result = await notificationsService.listMyNotifications(getUserId(req), {
unreadOnly: query.unread === 'true',
limit: query.limit,
cursor: query.cursor,
});
res.status(200).json(result);
}

export async function unreadCount(req: Request, res: Response) {
const count = await notificationsService.getUnreadCount(getUserId(req));
res.status(200).json({ count });
}

export async function markRead(req: Request, res: Response) {
const notification = await notificationsService.markMyNotificationRead(getUserId(req), getParam(req, 'id'));
res.status(200).json(notification);
}

export async function markAllRead(req: Request, res: Response) {
const result = await notificationsService.markAllMyNotificationsRead(getUserId(req));
res.status(200).json({ updated: result.count });
}
58 changes: 55 additions & 3 deletions backend/src/modules/notifications/repositories/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,55 @@
// notifications repositories — placeholder. Data access only. Not to be imported
// by other modules directly — go through services/ instead.
export {};
import type { Prisma } from '@prisma/client';
import { prisma } from '../../../db/prisma-client';

type DbClient = typeof prisma | Prisma.TransactionClient;

export function createNotification(
userId: string,
type: string,
payload: Prisma.InputJsonValue,
db: DbClient = prisma,
) {
return db.notification.create({ data: { userId, type, payload, channel: 'in_app' } });
}

export function listNotifications(
userId: string,
options: { unreadOnly: boolean; limit: number; cursor?: { createdAt: Date; id: string } },
) {
return prisma.notification.findMany({
where: {
userId,
...(options.unreadOnly ? { readAt: null } : {}),
...(options.cursor
? {
OR: [
{ createdAt: { lt: options.cursor.createdAt } },
{ createdAt: options.cursor.createdAt, id: { lt: options.cursor.id } },
],
}
: {}),
},
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
take: options.limit + 1,
});
}

export function countUnread(userId: string) {
return prisma.notification.count({ where: { userId, readAt: null } });
}

export async function markNotificationRead(userId: string, id: string) {
const updated = await prisma.notification.updateMany({
where: { id, userId, readAt: null },
data: { readAt: new Date() },
});
if (updated.count > 0) return prisma.notification.findUniqueOrThrow({ where: { id } });
return prisma.notification.findFirst({ where: { id, userId } });
}

export function markAllNotificationsRead(userId: string) {
return prisma.notification.updateMany({
where: { userId, readAt: null },
data: { readAt: new Date() },
});
}
14 changes: 11 additions & 3 deletions backend/src/modules/notifications/routes/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
// notifications routes — placeholder. See PRD.md / ARCHITECTURE.md §3 for this module's scope.
// TODO(Phase: see ROADMAP.md): define routes for notifications.
export {};
import { Router } from 'express';
import { authenticate } from '../../../middleware/auth';
import * as notificationsController from '../controllers';

export const notificationsRouter = Router();

notificationsRouter.use(authenticate);
notificationsRouter.get('/', notificationsController.list);
notificationsRouter.get('/unread-count', notificationsController.unreadCount);
notificationsRouter.post('/read-all', notificationsController.markAllRead);
notificationsRouter.post('/:id/read', notificationsController.markRead);
111 changes: 108 additions & 3 deletions backend/src/modules/notifications/services/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,108 @@
// notifications services — placeholder. Other modules must only import from here,
// never from repositories/ directly. See AGENTS.md "Non-negotiable rules".
export {};
import type { Prisma } from '@prisma/client';
import { z } from 'zod';
import { HttpError } from '../../../http-error';
import * as notificationsRepo from '../repositories';

const uuid = z.string().uuid();

// Payloads deliberately contain identifiers only, never clinical details.
export const notificationPayloadSchemas = {
booking_confirmed: z.object({ appointmentId: uuid }).strict(),
new_booking: z.object({ appointmentId: uuid }).strict(),
referral_created: z.object({ referralId: uuid }).strict(),
referral_updated: z.object({ referralId: uuid }).strict(),
order_ready: z.object({ orderId: uuid, orderType: z.enum(['prescription', 'lab_order']) }).strict(),
results_available: z.object({ labOrderId: uuid }).strict(),
} as const;

export type NotificationType = keyof typeof notificationPayloadSchemas;
export type NotificationPayload<T extends NotificationType> = z.infer<(typeof notificationPayloadSchemas)[T]>;
export type NotificationDispatch = {
notificationId: string;
userId: string;
type: NotificationType;
payload: NotificationPayload<NotificationType>;
};

export interface NotificationChannelAdapter {
channel: 'email' | 'sms';
send(notification: NotificationDispatch): Promise<void>;
}

const adapters = new Set<NotificationChannelAdapter>();

export function registerChannelAdapter(adapter: NotificationChannelAdapter): () => void {
adapters.add(adapter);
return () => adapters.delete(adapter);
}

export async function notify<T extends NotificationType>(userId: string, type: T, payload: unknown) {
const parsed = notificationPayloadSchemas[type].safeParse(payload);
if (!parsed.success) throw new HttpError(400, 'Invalid notification payload.', 'INVALID_NOTIFICATION_PAYLOAD');

const notification = await notificationsRepo.createNotification(
userId,
type,
parsed.data as Prisma.InputJsonValue,
);
const dispatch: NotificationDispatch = {
notificationId: notification.id,
userId,
type,
payload: parsed.data as NotificationPayload<NotificationType>,
};

// Provider failures must not roll back or hide a persisted in-app notice.
await Promise.allSettled([...adapters].map((adapter) => Promise.resolve().then(() => adapter.send(dispatch))));
return notification;
}

export async function listMyNotifications(
userId: string,
options: { unreadOnly: boolean; limit: number; cursor?: string },
) {
const cursor = options.cursor ? decodeCursor(options.cursor) : undefined;
const rows = await notificationsRepo.listNotifications(userId, { ...options, cursor });
const hasMore = rows.length > options.limit;
const items = hasMore ? rows.slice(0, options.limit) : rows;
return { items, nextCursor: hasMore ? encodeCursor(items[items.length - 1]) : null };
}

export function getUnreadCount(userId: string) {
return notificationsRepo.countUnread(userId);
}

export async function markMyNotificationRead(userId: string, id: string) {
const notification = await notificationsRepo.markNotificationRead(userId, id);
if (!notification) throw new HttpError(404, 'Notification not found.');
return notification;
}

export function markAllMyNotificationsRead(userId: string) {
return notificationsRepo.markAllNotificationsRead(userId);
}

function encodeCursor(notification: { createdAt: Date; id: string }): string {
return Buffer.from(`${notification.createdAt.toISOString()}|${notification.id}`).toString('base64url');
}

function decodeCursor(value: string): { createdAt: Date; id: string } {
try {
const decoded = Buffer.from(value, 'base64url').toString('utf8');
const separator = decoded.indexOf('|');
const createdAtText = decoded.slice(0, separator);
const id = decoded.slice(separator + 1);
const createdAt = new Date(createdAtText);
if (
separator < 1 ||
!Number.isFinite(createdAt.getTime()) ||
createdAt.toISOString() !== createdAtText ||
!uuid.safeParse(id).success
) {
throw new Error('Invalid cursor');
}
return { createdAt, id };
} catch {
throw new HttpError(400, 'Invalid cursor.', 'INVALID_CURSOR');
}
}
Loading
Loading