A full-stack appointment booking system for medical clinics. Patients can book a doctor's appointment without creating an account, or register to view and cancel their bookings later.
For visitors
- Search for doctors by name and see their clinic and speciality
- Book an appointment as a guest, without registering
- Register an account, then log in to view and cancel bookings
Behind the API
- Full CRUD for doctors, clinics, specialities, appointment categories, patients, and appointments
- JWT authentication with BCrypt-hashed passwords
- Administrative endpoints are protected; public reference data and the guest booking path are not
The Patient model separates non-sensitive fields (name, email, gender, date of birth) from sensitive ones (SSN, tax number, driver's licence, insurance number, religion). An IsGuest flag drives the distinction, and the API discards sensitive fields entirely when creating a guest. Someone who books once without an account should not leave identifying data behind.
DELETE /api/appointments/{id} does not remove the record. It sets the status to Cancelled, so the clinic keeps its booking history and can still see what was scheduled and dropped.
Controllers accept purpose-built DTOs rather than EF entities. This keeps navigation properties out of the API contract, so a client sending a booking is not offered a nested patient object with its own nested appointments. It also means the database model can change without silently changing what the API accepts.
The schema is defined in C# model classes and generated through EF Core migrations, so the database can be rebuilt from scratch on any machine with one command, and schema changes are versioned alongside the code.
Date constraints are enforced in the browser (appointments cannot be booked in the past, dates of birth cannot be in the future) and again in the controller, since client-side rules can be bypassed by calling the API directly.
| Layer | Technology |
|---|---|
| Database | MySQL 8.0 |
| Back-end | ASP.NET Core 9 Web API, Entity Framework Core (code-first) |
| Front-end | React with Vite |
| Auth | JWT bearer tokens, BCrypt password hashing |
| Docs | Swagger / OpenAPI |
- .NET 9 SDK
- MySQL Server 8.0
- Node.js
Copy Backend/ClinicApi/appsettingsExample.json to appsettings.json and fill in your own values:
"ConnectionStrings": {
"DefaultConnection": "Server=localhost;Database=ClinicDb;User=root;Password=YOUR_PASSWORD;"
},
"Jwt": {
"Key": "YOUR_32_CHAR_SECRET_KEY",
"Issuer": "ClinicApi",
"Audience": "ClinicFrontend"
}appsettings.json is gitignored so credentials never reach the repository. The JWT key must be at least 32 characters.
cd Backend/ClinicApi
dotnet restore
dotnet ef migrations add InitialCreate
dotnet ef database updateThis creates the ClinicDb database and all tables.
dotnet run- API:
http://localhost:5243 - Swagger:
http://localhost:5243/doc
In a second terminal:
cd Frontend/clinic-frontend
npm install
npm run devThe app opens at http://localhost:5173.
- Register an account via
POST /api/auth/register - Call
POST /api/auth/loginand copy the token from the response - Click Authorize at the top of the page
- Enter
Bearer {your token}and confirm
Endpoints marked No are open because the guest booking flow and the public search depend on them. Everything else requires a valid JWT.
| Method | Endpoint | Description | Auth |
|---|---|---|---|
| POST | /api/auth/register | Register a new patient account | No |
| POST | /api/auth/login | Log in and receive a JWT | No |
| Method | Endpoint | Description | Auth |
|---|---|---|---|
| GET | /api/doctors | Get all doctors | No |
| GET | /api/doctors/{id} | Get one doctor by ID | No |
| GET | /api/doctors/search?name={name} | Search by first or last name | No |
| POST | /api/doctors | Create a doctor | Yes |
| PUT | /api/doctors/{id} | Update a doctor | Yes |
| DELETE | /api/doctors/{id} | Delete a doctor | Yes |
| Method | Endpoint | Description | Auth |
|---|---|---|---|
| GET | /api/clinics | Get all clinics | No |
| GET | /api/clinics/{id} | Get one clinic by ID | No |
| POST | /api/clinics | Create a clinic | Yes |
| PUT | /api/clinics/{id} | Update a clinic | Yes |
| DELETE | /api/clinics/{id} | Delete a clinic | Yes |
| Method | Endpoint | Description | Auth |
|---|---|---|---|
| GET | /api/specialities | Get all specialities | No |
| GET | /api/specialities/{id} | Get one speciality by ID | No |
| POST | /api/specialities | Create a speciality | Yes |
| PUT | /api/specialities/{id} | Update a speciality | Yes |
| DELETE | /api/specialities/{id} | Delete a speciality | Yes |
| Method | Endpoint | Description | Auth |
|---|---|---|---|
| GET | /api/categories | Get all categories | No |
| GET | /api/categories/{id} | Get one category by ID | No |
| POST | /api/categories | Create a category | Yes |
| PUT | /api/categories/{id} | Update a category | Yes |
| DELETE | /api/categories/{id} | Delete a category | Yes |
| Method | Endpoint | Description | Auth |
|---|---|---|---|
| GET | /api/patients | Get all patients | Yes |
| GET | /api/patients/{id} | Get one patient by ID | Yes |
| POST | /api/patients | Create a patient (guest booking) | No |
| PUT | /api/patients/{id} | Update a patient | Yes |
| DELETE | /api/patients/{id} | Delete a patient | Yes |
| Method | Endpoint | Description | Auth |
|---|---|---|---|
| GET | /api/appointments | Get all appointments | Yes |
| GET | /api/appointments/{id} | Get one appointment by ID | Yes |
| GET | /api/appointments/patient/{patientId} | Get a patient's appointments | Yes |
| POST | /api/appointments | Book an appointment | No |
| PUT | /api/appointments/{id} | Update an appointment | Yes |
| DELETE | /api/appointments/{id} | Cancel an appointment | Yes |
No per-user authorization. Protected endpoints check that a caller is authenticated, but not which patient they are. A logged-in user could read another patient's appointments by changing the ID in the URL. The fix is to compare the patientId in the route against the claim in the token, and it is the first thing I would add.
No role separation. Every authenticated user has the same access, so a registered patient can reach endpoints intended for clinic staff. The User model already carries a Role, so the groundwork is there — the controllers would need [Authorize(Roles = "Admin")] on administrative actions.
Circular dependency between patients and users. User references Patient and Patient references User. It works, but a one-directional relationship with the foreign key on User alone would be cleaner.
Front-end token storage. The JWT is kept in localStorage, which is readable by any script on the page. An httpOnly cookie set by the server would be the stronger choice.
| Package | Version | Purpose |
|---|---|---|
| Pomelo.EntityFrameworkCore.MySql | 9.0.0 | MySQL provider for EF Core |
| Microsoft.EntityFrameworkCore.Design | 9.0.0 | Required for EF migration commands |
| Microsoft.AspNetCore.Authentication.JwtBearer | 9.0.0 | JWT bearer authentication |
| Swashbuckle.AspNetCore | 6.5.0 | Swagger UI and API documentation |
| BCrypt.Net-Next | 4.2.0 | Password hashing |
| Package | Purpose |
|---|---|
| react-router-dom | Client-side routing |
| axios | HTTP requests to the API |
- ASP.NET Core documentation
- Entity Framework Core documentation
- Pomelo MySQL EF provider
- React documentation
- React Router
- Axios
- BCrypt.Net
- Swashbuckle
- JWT bearer authentication
Claude was used during development to help debug errors, discuss which approaches suited particular situations, and explain unfamiliar parts of the code.