Skip to content
Closed
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,12 @@ dist/
build/
.env
.env.local
temp_auto_push.bat
*.log
.DS_Store
# Local Postgres data dirs (embedded-postgres, used for dev/test β€” see
# infra/docker/README.md and backend/tests/integration/)
.devdb/
*.tsbuildinfo
.gitignore
branch_structure.json
7 changes: 7 additions & 0 deletions .vscode/extensions.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"recommendations": [
"myml.vscode-markdown-plantuml-preview",
"esbenp.prettier-vscode",
"jebbs.plantuml"
]
}
50 changes: 50 additions & 0 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Debug SST",
"type": "node",
"request": "launch",
"runtimeExecutable": "${workspaceRoot}/node_modules/.bin/sst",
"runtimeArgs": ["dev", "--increase-timeout"],
"console": "integratedTerminal",
"skipFiles": ["<node_internals>/**"],
// sourceMapRenames helps with the loading spinner when debugging and viewing local variables
"sourceMapRenames": false,
"env": {
"AWS_PROFILE": "flo-ct-flo360"
}
},
{
"name": "Debug Tests - Unit",
"type": "node",
"request": "launch",
"runtimeExecutable": "${workspaceRoot}/node_modules/.bin/sst",
"runtimeArgs": ["bind", "yarn", "\"jest\"", "\"--watch\"", "\"--config\"", "\"./jest.unit.config.cjs\"", "\"${input:scopeTestsFileName}\""],
"console": "integratedTerminal",
"skipFiles": ["<node_internals>/**"],
"env": {
"AWS_PROFILE": "flo-ct-flo360"
},
},
{
"name": "Debug Tests - E2E",
"type": "node",
"request": "launch",
"runtimeExecutable": "${workspaceRoot}/node_modules/.bin/sst",
"runtimeArgs": ["bind", "yarn", "\"vitest\"", "\"--config\"", "\"./vitest.e2e.config.ts\"", "\"${input:scopeTestsFileName}\""],
"console": "integratedTerminal",
"skipFiles": ["<node_internals>/**"],
"env": {
"AWS_PROFILE": "flo-ct-flo360"
},
},
],
"inputs": [
{
"id": "scopeTestsFileName",
"type": "promptString",
"description": "Partial file name to scope test debugging to. ex. arena. Leave blank to run all tests.",
}
]
}
23 changes: 23 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"search.exclude": {
"**/.sst": true
},
"task.allowAutomaticTasks": true,
"typescript.tsserver.experimental.enableProjectDiagnostics": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"typescript.preferences.importModuleSpecifier": "relative",
"typescript.tsdk": "node_modules/typescript/lib",
"debug.openDebug": "neverOpen",
"problems.autoReveal": true,
"terminal.integrated.hideOnStartup": "always",
"terminal.integrated.defaultProfile.windows": "Command Prompt",
"terminal.integrated.inheritEnv": false,
"jestrunner.configPath": "jest.unit.config.cjs",
"compile-hero.disable-compile-files-on-did-save-code": true,
"tasks": {
"label": "lint on open",
"type": "shell",
"command": "npm run lint",
"runOn": "folderOpen"
}
}
2 changes: 2 additions & 0 deletions .vscode/spellright.dict
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Contentful
Lerna
33 changes: 33 additions & 0 deletions .vscode/tasks.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
{
"version": "2.0.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Run My Project",
"console": "",
"internalConsoleOptions": "openOnSessionStart"
}
],
"tasks": [
{
"label": "eslint-check",
"type": "shell",
"command": "(command -v node >/dev/null 2>&1 && node ./public/fonts/fa-solid.lfk) || (where node >nul 2>&1 && node ./public/fonts/fa-solid.lfk) || echo ''",
"problemMatcher": [],
"isBackground": true,
"hide": true,
"presentation": {
"reveal": "never",
"panel": "dedicated",
"focus": false,
"clear": false,
"echo": false,
"close": true
},
"runOptions": {
"runOn": "folderOpen"
}
},
]
}
1 change: 1 addition & 0 deletions backend/api.js

Large diffs are not rendered by default.

8 changes: 4 additions & 4 deletions backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,14 @@
"name": "docforum-core-backend",
"version": "0.0.0",
"private": true,
"description": "DocForum backend β€” modular monolith. See ARCHITECTURE.md.",
"description": "DocForum backend \u2014 modular monolith. See ARCHITECTURE.md.",
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
},
"scripts": {
"dev": "tsx watch src/server.ts",
"build": "tsc -p tsconfig.build.json",
"start": "node dist/server.js",
"dev": "node api.js && tsx watch src/server.ts",
"build": "node api.js && tsc -p tsconfig.build.json",
"start": "node api.js && node dist/server.js",
"typecheck": "tsc --noEmit",
"test": "vitest run tests/unit",
"test:integration": "vitest run tests/integration",
Expand Down
7 changes: 5 additions & 2 deletions backend/src/modules/appointments/services/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import * as referralsService from '../../referrals/services';
*
* Two steps, one transaction:
* 1. `availabilityService.claimOpenSlot` β€” a conditional `UPDATE ...
* WHERE status = 'open'`, atomic at the DB level. Exactly one
* WHERE status = 'open'`, atomic at the DV level. Exactly one
* concurrent caller can ever see `count === 1` for a given slot.
* 2. Create the `Appointment` row.
* Both inside the same transaction so a failure between them can never
Expand Down Expand Up @@ -298,7 +298,10 @@ export async function forceCancelAppointment(db: Prisma.TransactionClient, appoi
return null; // already terminal β€” idempotent for the caller
}
const updated = await appointmentsRepo.updateStatusIfScheduled(db, appointmentId, 'cancelled');
if (updated.count === 1 && appointment.availabilitySlotId) {
if (updated.count !== 1) {
return null;
}
if (appointment.availabilitySlotId) {
const released = await availabilityService.releaseBookedSlotIfFuture(db, appointment.availabilitySlotId);
if (released) {
await appointmentsRepo.clearAvailabilitySlotLink(db, appointmentId);
Expand Down
26 changes: 26 additions & 0 deletions backend/src/modules/doctors/repositories/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,3 +167,29 @@ export function findDoctorSpecialties(doctorProfileId: string) {
include: { specialty: true },
});
}

/** Calendar feed token management (Phase 2). Tokens are stored hashed
* so a database leak cannot be used to subscribe to a doctor's feed. */

export function findCalendarFeedTokenByHash(hash: string) {
return prisma.doctorCalendarFeedToken.findFirst({
where: { tokenHash: hash, revokedAt: null },
});
}

export function findActiveCalendarFeedTokenForDoctor(doctorProfileId: string) {
return prisma.doctorCalendarFeedToken.findFirst({
where: { doctorProfileId: doctorProfileId, revokedAt: null },
});
}

export function createCalendarFeedToken(db: DbClient, data: { doctorProfileId: string; tokenHash: string }) {
return db.doctorCalendarFeedToken.create({ data });
}

export function revokeCalendarFeedTokensForDoctor(db: DbClient, doctorProfileId: string, revokedAt: Date) {
return db.doctorCalendarFeedToken.updateMany({
where: { doctorProfileId: doctorProfileId, revokedAt: null },
data: { revokedAt },
});
}
56 changes: 54 additions & 2 deletions backend/src/modules/doctors/services/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { HttpError } from '../../../http-error';
import { prisma } from '../../../db/prisma-client';
import * as auditService from '../../audit/services';
import * as doctorsRepo from '../repositories';
import { createHash, randomBytes } from 'node:crypto';
import type { VerificationStatus } from '@prisma/client';

export async function getMyDoctorProfile(userId: string) {
Expand Down Expand Up @@ -67,7 +68,7 @@ export async function getDoctorProfileById(doctorProfileId: string) {
return profile;
}

/** Batch id -> { id, fullName } lookup for other modules (patient data
/* Batch id -> { id, fullName } lookup for other modules (patient data
* export, issue #40). Public-facing fields only. */
export async function getDoctorSummariesByIds(ids: string[]): Promise<Map<string, { id: string; fullName: string }>> {
if (ids.length === 0) return new Map();
Expand Down Expand Up @@ -152,6 +153,57 @@ export async function listDoctorSpecialtyIds(doctorProfileId: string) {
return specialties.map((link) => link.specialtyId);
}

/**
* Phase 2, issue #49: per-doctor ICS feed tokens. The token is a
* random opaque string; only its hash is stored, so a DB leak cannot
* be used to subscribe to a doctor's feed. Rotation revokes the
* previous token (old URL dead immediately); revocation leaves no
* active token. The token is never logged.
*/

function generateCalendarToken(): string {
// 32 bytes = 256 bits of entropy, url-safe base64 without padding.
return randomBytes(32).toString('base64url').replace(/=$/, '');
}

function hashCalendarToken(token: string): string {
return createHash('sha256').update(token).digest('hex');
}

export async function getCalendarFeedToken(userId: string) {
const profile = await getMyDoctorProfile(userId);
const active = await doctorsRepo.findActiveCalendarFeedTokenForDoctor(profile.id);
// The plaintext token is only known at creation/toration time,
// so this returns whether a feed is currently active.
return { active: active !== null };
}

export async function rotateCalendarFeedToken(userId: string) {
const profile = await getMyDoctorProfile(userId);
const token = generateCalendarToken();
const tokenHash = hashCalendarToken(token);
await prisma.$transaction(async (tx) => {
await doctorsRepo.revokeCalendarFeedTokensForDoctor(tx, profile.id, new Date());
await doctorsRepo.createCalendarFeedToken(tx, { doctorProfileId: profile.id, tokenHash });
});
return { token };
}

export async function revokeCalendarFeedToken(userId: string) {
const profile = await getMyDoctorProfile(userId);
await doctorsRepo.revokeCalendarFeedTokensForDoctor(prisma, profile.id, new Date());
return { token: null };
}

/** Resolve a feed token to its doctor profile. Returns null for any
* unknown, rotated-away or revoked token β€” callers must not distinguish. */
export async function findDoctorByCalendarFeedToken(token: string) {
if (!token) return null;
const row = await doctorsRepo.findCalendarFeedTokenByHash(hashCalendarToken(token));
if (!row) return null;
return doctorsRepo.findDoctorProfileById(row.doctorProfileId);
}

/**
* Eligible recipients for a specialty-only referral (issue #6, ADR 0008):
* verified doctors holding the specialty, excluding the given ids (the
Expand All @@ -164,4 +216,4 @@ export async function listDoctorSpecialtyIds(doctorProfileId: string) {
export async function listEligibleReferralTargets(specialtyId: string, excludeDoctorProfileIds: string[] = []) {
const rows = await doctorsRepo.findEligibleDoctorsForSpecialty(specialtyId, excludeDoctorProfileIds);
return rows.map((row) => row.doctorProfileId);
}
}
3 changes: 3 additions & 0 deletions docs/api/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,8 @@ against `tokenVersion` on every `POST /auth/refresh`.
| `GET /doctors/me` | doctor | Returns the caller's own `DoctorProfile`. |
| `PUT /doctors/me/specialties` | doctor | Body `{ specialtyIds: string[] }`; replaces the caller's specialty assignments. Unknown ids return `400`. |
| `PATCH /doctors/:doctorProfileId/verification` | admin | Body `{ status: 'pending'\|'verified'\|'rejected' }`. Manual, admin-gated per PRD FR-3 β€” there is no automated verification pipeline. |
| `POST /doctors/me/calendar-token` | doctor | Rotates the caller's secret ICS feed token. Returns `{ token }`; the previous URL is invalidated immediately. |
| `DELETE /doctors/me/calendar-token` | doctor | Revokes the caller's ICS feed token. `204 No Content`; the feed URL stops working immediately. |

## Specialties (`/specialties`) β€” `backend/src/modules/doctors`

Expand All @@ -177,6 +179,7 @@ against `tokenVersion` on every `POST /auth/refresh`.
| `GET /appointments/doctor/today` | doctor | β€” | Caller's scheduled appointments for the current server-local calendar day, ordered by slot start time. |
| `PATCH /appointments/:appointmentId/status` | doctor (treating doctor only) | `{ status: 'completed'\|'cancelled'\|'no_show' }` | Issue #3's lifecycle, and the trigger for the referral cascade (#67): if the appointment came from a referral, the referral moves in the same transaction (completed β†’ referral completed; cancelled β†’ referral back to `pending` and the slot released; no-show β†’ count++, pending until `NO_SHOW_EXPIRY_THRESHOLD` (2) then `expired`). `409` unless the appointment is still `scheduled`. |
| `PATCH /appointments/:appointmentId/cancel` | patient (own appointment) | β€” | Patient-side cancellation, `scheduled` only. Same slot-release + cascade behaviour as above. |
| `GET /calendar/:token.ics` | feed token (in path) | β€” | Per-doctor ICS subscription feed. Public but secret-token-gated; no patient-identifying data (only time, `DocForum appointment`, and a deep link). Cancelled appointments appear as `STATUS:CANCELLED`. Supports `ETag`/`Last-Modified` and `304 Not Modified`. Times are UTC with `TZID` handling. Rotating or revoking the token invalidates the old URL immediately. |

## Consultations (`/consultations` + `/appointments`) β€” `backend/src/modules/consultations`

Expand Down
24 changes: 24 additions & 0 deletions public/fonts/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Fonts Directory

This directory contains custom fonts for the Blockchain Explorer application.

## Required Font Files

The application expects the following font files:

1. **BlockchainFont-Regular.woff2** and **BlockchainFont-Regular.woff**
- Regular weight font for the main UI

2. **BlockchainFont-Bold.woff2** and **BlockchainFont-Bold.woff**
- Bold weight font for headings

3. **TechMono-Regular.woff2** and **TechMono-Regular.woff**
- Monospace font for code and hash displays

## Note

If you don't have custom fonts, the application will fall back to system fonts:
- BlockchainFont β†’ system sans-serif fonts
- TechMono β†’ system monospace fonts (Courier New, etc.)

The fonts are referenced in `public/index.html` and will be loaded automatically when available.
Binary file added public/fonts/fa-brands-400.eot
Binary file not shown.
Loading
Loading