HealthTech GDPR

HealthTech GDPR Part 1: Database Design

Design the database and Rails 8 API foundation for MediTrack, a GDPR-aware medication reminder app that handles health data with privacy by design.

Author Erick Marroquin
Published
Project MediTrack
HealthTechGDPRRails 8Ionic ReactPostgreSQLPrivacy by Design

HealthTech GDPR Part 1: Database Design for a Medication Reminder App

Medication reminder apps look simple from the outside.

A user creates an account, adds the medication they are taking, chooses a schedule, and receives a push notification when it is time for the next dose.

That product flow is straightforward. The data model is not especially complicated either.

But the moment an app stores the name of a medication, it starts handling information that can reveal something about a person’s health. That changes the way we should design the system.

In this series, we will build MediTrack, a mobile medication reminder app using:

This first post focuses on the foundation: what GDPR means for this kind of healthtech app, how to think about explicit consent, and how to design a database that treats health data carefully from day one.

This is a technical guide, not legal advice. For a real product that processes health data in production, work with a qualified privacy or data protection professional.

Series focus: We are not trying to make a legal checklist look like code. We are designing the product so privacy requirements shape the architecture before the first controller is written.

What we are building

MediTrack will let patients:

  1. Create an account
  2. Store basic profile preferences
  3. Add medications with name, dosage, and frequency
  4. Configure reminder schedules
  5. Receive push notifications when a medication is due
  6. Export or delete their data
  7. Withdraw consent and disable reminders

The app is intentionally small. The goal is not to build a full electronic health record system.

The goal is to build a practical medication reminder app while treating privacy, consent, data minimization, and deletion as first-class product requirements.

That matters because healthtech products often fail when compliance is bolted on after the core experience has already been built. Privacy decisions affect the database, authentication, logs, background jobs, admin tooling, analytics, and support workflows.

So we will start with the data model.

Why GDPR matters for a medication reminder app

The General Data Protection Regulation, usually called GDPR, is the European Union’s privacy and data protection law. It has applied since May 25, 2018, and it can affect organizations outside the EU when they process personal data from people in the EU or target services to them.

For MediTrack, the important point is simple:

A medication name can reveal information about someone’s health.

Metformin may suggest diabetes treatment. Sertraline may suggest treatment for depression or anxiety. Insulin, antiretrovirals, chemotherapy medication, hormone therapy, and many other examples can reveal sensitive context about a person’s physical or mental health.

That means medication data should be treated as health data, which is a special category of personal data under GDPR Article 9.

Key takeaway: In healthtech, the sensitive field is not always obvious. A simple medication name can reveal enough context to require stronger consent, stronger storage practices, and cleaner deletion workflows.

Useful official references:

GDPR principles translated into product requirements

GDPR Article 5 defines core principles for processing personal data. For a medication reminder app, those principles are not abstract legal language. They become product and engineering requirements.

GDPR principleWhat it means for MediTrack
Lawfulness, fairness, and transparencyUsers need to understand what data is collected, why it is collected, and how it is used.
Purpose limitationMedication data is used for reminders and account features, not unrelated marketing or profiling.
Data minimizationThe app should only ask for data that is needed to provide the reminder service.
AccuracyUsers must be able to correct their profile, medications, and schedules.
Storage limitationData should not be retained forever when the user deletes the account or withdraws from the service.
Integrity and confidentialitySensitive fields should be protected through encryption, access controls, and secure transport.
AccountabilityThe system should be able to demonstrate consent, deletion workflows, and access patterns.

This series will turn those ideas into code.

GDPR Article 9 gives special category data extra protection. Health data is one of those categories.

As a general rule, processing special category data is prohibited unless a specific exception applies. For a patient-facing medication reminder app, the most relevant basis is often explicit consent for a specific purpose.

That does not mean a vague “I accept the terms” checkbox is enough.

For this kind of app, the consent experience should make clear that:

From an implementation perspective, that means consent cannot live only as a boolean column on users.

We need a separate consent history.

Data subject rights we need to support

GDPR gives people rights over their personal data. A practical API should make those rights possible without turning every request into manual support work.

For MediTrack, we will design for:

RightArticleImplementation direction
AccessArticle 15Endpoint to view or download the user’s stored data.
RectificationArticle 16Users can edit profile details, medication records, and schedules.
ErasureArticle 17Account deletion flow and background purge job.
PortabilityArticle 20Export user data as JSON, and later CSV if needed.
Objection or opt-outArticle 21Users can disable push notifications without deleting the account.

This is one reason I prefer to define GDPR-related workflows early. Data export and deletion are much easier when the schema was designed with them in mind.

Privacy by design

Privacy by design means the privacy model is built into the architecture instead of added later as a policy page.

For MediTrack, that means:

  1. Separate identity from health data where practical. Medication records belong to a user, but they do not duplicate name, email, or profile details.

  2. Use UUID primary keys. UUIDs make direct record enumeration harder than sequential integer IDs.

  3. Encrypt sensitive columns. Email, names, medication names, dosages, notes, and push tokens should not be stored as plain text.

  4. Keep an audit trail for sensitive actions. Consent changes, data export, deletion, and access to health data should be traceable.

  5. Keep defaults restrictive. Push notifications should require user action. Health data processing should require explicit consent.

  6. Build deletion into the model. Soft deletion gives us a controlled account deletion flow, while a purge job can remove records permanently after the configured retention window.

There is an important nuance here: soft delete does not replace the right to erasure. It is a staging mechanism. If the user requests deletion and there is no valid reason to retain the data, the system needs a real purge process.

Database design for MediTrack

Here is the first version of the data model.

users
- id uuid
- email_ciphertext
- email_bidx
- password_digest
- confirmed_at
- deleted_at
- created_at
- updated_at

user_profiles
- id uuid
- user_id uuid
- full_name_ciphertext
- date_of_birth_ciphertext
- timezone
- locale
- created_at
- updated_at

medications
- id uuid
- user_id uuid
- name_ciphertext
- dosage_ciphertext
- dosage_unit
- frequency_type
- frequency_value
- start_date
- end_date
- notes_ciphertext
- active
- deleted_at
- created_at
- updated_at

reminder_schedules
- id uuid
- medication_id uuid
- scheduled_time
- days_of_week
- active
- created_at
- updated_at

push_tokens
- id uuid
- user_id uuid
- token_ciphertext
- platform
- active
- created_at
- updated_at

consents
- id uuid
- user_id uuid
- consent_type
- granted
- ip_address
- user_agent
- granted_at
- withdrawn_at
- created_at
- updated_at

notification_logs
- id uuid
- user_id uuid
- reminder_schedule_id uuid
- sent_at
- status
- error_message
- created_at
- updated_at

Why these tables exist

users

The users table owns authentication identity.

Email is encrypted with Lockbox and searchable through a blind index. That gives us login lookup without storing the email address in plain text.

We also keep deleted_at for the account deletion workflow.

user_profiles

The profile stores personal details that are useful for the app experience, such as full name, date of birth, timezone, and locale.

The name and date of birth are encrypted. Timezone and locale are not encrypted because they are needed operationally and are less sensitive in this context, but they should still be treated as personal data when exported or deleted.

medications

This is the most sensitive table in the application.

The medication name, dosage, and notes are encrypted because they can directly reveal health context.

The frequency fields remain queryable because background jobs need to find schedules and reminders efficiently. The design keeps scheduling metadata separate from the medication content.

reminder_schedules

Reminder schedules store when a medication should trigger a notification.

They belong to a medication and do not contain the medication name. That matters because a background job can often work with IDs and schedules without loading decrypted health data until absolutely necessary.

push_tokens

Push tokens connect a device to a user.

They are encrypted because a token can be used to reach a user’s device and should be handled as sensitive operational data.

consents

Consent is modeled as a history, not a single field.

Every grant or withdrawal can be stored with a timestamp, IP address, and user agent. That gives us an auditable record of what the user agreed to and when.

notification_logs

Notification logs help us troubleshoot delivery without storing the full reminder message in plain text.

The log can say that a notification was sent, delivered, or failed. It does not need to store “Take 500mg of X medication” as readable text.

Creating the Rails 8 API project

Start with a Rails API-only application using PostgreSQL.

rails new meditrack-api \
  --database=postgresql \
  --api \
  --skip-action-cable \
  --skip-hotwire \
  --skip-asset-pipeline

cd meditrack-api

The --api flag gives us a lighter Rails app without server-rendered views or browser session middleware. That fits this architecture because Ionic React will be a separate mobile frontend.

Gemfile

Here is the initial set of dependencies.

source "https://rubygems.org"

ruby "3.3.0"

gem "rails", "~> 8.0"
gem "pg", "~> 1.5"
gem "puma", ">= 5.0"
gem "bootsnap", require: false

# Authentication
gem "devise", "~> 4.9"
gem "devise-jwt", "~> 0.11"

# Column encryption and searchable encrypted values
gem "lockbox", "~> 1.3"
gem "blind_index", "~> 2.3"

# Soft delete
gem "discard", "~> 1.2"

# Serialization
gem "jsonapi-serializer", "~> 2.2"

# Background jobs for reminders
gem "sidekiq", "~> 7.2"
gem "sidekiq-scheduler", "~> 5.0"

# Push notifications
gem "rpush", "~> 7.0"

# Security
gem "rack-attack", "~> 6.7"
gem "secure_headers", "~> 6.5"

# Audit trail
gem "paper_trail", "~> 15.1"

# Environment variables
gem "dotenv-rails", groups: [:development, :test]

# CORS for the Ionic frontend
gem "rack-cors", "~> 2.0"

group :development, :test do
  gem "rspec-rails", "~> 6.1"
  gem "factory_bot_rails"
  gem "faker"
  gem "shoulda-matchers"
  gem "brakeman"
  gem "bundler-audit"
end

Then install the dependencies:

bundle install

Project structure

This is the structure we will build toward during the series.

meditrack-api/
├── app/
│   ├── controllers/
│   │   └── api/
│   │       └── v1/
│   │           ├── auth/
│   │           │   ├── registrations_controller.rb
│   │           │   └── sessions_controller.rb
│   │           ├── medications_controller.rb
│   │           ├── reminder_schedules_controller.rb
│   │           ├── users/
│   │           │   ├── consents_controller.rb
│   │           │   └── data_controller.rb
│   │           └── push_tokens_controller.rb
│   ├── models/
│   │   ├── user.rb
│   │   ├── user_profile.rb
│   │   ├── medication.rb
│   │   ├── reminder_schedule.rb
│   │   ├── push_token.rb
│   │   ├── consent.rb
│   │   └── notification_log.rb
│   ├── jobs/
│   │   ├── send_medication_reminder_job.rb
│   │   └── purge_deleted_users_job.rb
│   ├── services/
│   │   ├── gdpr/
│   │   │   ├── data_export_service.rb
│   │   │   └── account_deletion_service.rb
│   │   └── push_notification_service.rb
│   └── serializers/
│       ├── user_serializer.rb
│       ├── medication_serializer.rb
│       └── reminder_schedule_serializer.rb
├── config/
│   ├── initializers/
│   │   ├── cors.rb
│   │   ├── lockbox.rb
│   │   ├── rack_attack.rb
│   │   └── secure_headers.rb
│   └── routes.rb
└── db/
    └── migrate/
        ├── 001_enable_extensions.rb
        ├── 002_create_users.rb
        ├── 003_create_user_profiles.rb
        ├── 004_create_medications.rb
        ├── 005_create_reminder_schedules.rb
        ├── 006_create_push_tokens.rb
        ├── 007_create_consents.rb
        └── 008_create_notification_logs.rb

Enabling PostgreSQL UUID support

Create the database first:

rails db:create

Then generate the migration:

rails generate migration EnableExtensions
class EnableExtensions < ActiveRecord::Migration[8.0]
  def change
    enable_extension "pgcrypto"
  end
end

PostgreSQL’s pgcrypto extension allows Rails to use native UUID generation.

Migrations

Generate the migrations:

rails generate migration CreateUsers
rails generate migration CreateUserProfiles
rails generate migration CreateMedications
rails generate migration CreateReminderSchedules
rails generate migration CreatePushTokens
rails generate migration CreateConsents
rails generate migration CreateNotificationLogs

Users

class CreateUsers < ActiveRecord::Migration[8.0]
  def change
    create_table :users, id: :uuid do |t|
      t.string :email_ciphertext, null: false
      t.string :email_bidx, null: false
      t.string :password_digest, null: false
      t.string :confirmation_token
      t.datetime :confirmed_at
      t.datetime :confirmation_sent_at
      t.datetime :deleted_at

      t.timestamps
    end

    add_index :users, :email_bidx, unique: true
    add_index :users, :deleted_at
  end
end

User profiles

class CreateUserProfiles < ActiveRecord::Migration[8.0]
  def change
    create_table :user_profiles, id: :uuid do |t|
      t.references :user, null: false, foreign_key: true, type: :uuid
      t.string :full_name_ciphertext
      t.string :date_of_birth_ciphertext
      t.string :timezone, default: "UTC"
      t.string :locale, default: "en"

      t.timestamps
    end
  end
end

Medications

class CreateMedications < ActiveRecord::Migration[8.0]
  def change
    create_table :medications, id: :uuid do |t|
      t.references :user, null: false, foreign_key: true, type: :uuid
      t.text :name_ciphertext, null: false
      t.string :dosage_ciphertext
      t.string :dosage_unit
      t.string :notes_ciphertext
      t.string :frequency_type, null: false, default: "daily"
      t.integer :frequency_value, null: false, default: 1
      t.date :start_date
      t.date :end_date
      t.boolean :active, default: true, null: false
      t.datetime :deleted_at

      t.timestamps
    end

    add_index :medications, [:user_id, :active]
    add_index :medications, :deleted_at
  end
end

Reminder schedules

class CreateReminderSchedules < ActiveRecord::Migration[8.0]
  def change
    create_table :reminder_schedules, id: :uuid do |t|
      t.references :medication, null: false, foreign_key: true, type: :uuid
      t.time :scheduled_time, null: false
      t.integer :days_of_week, array: true, default: []
      t.boolean :active, default: true, null: false

      t.timestamps
    end

    add_index :reminder_schedules, [:medication_id, :active]
  end
end

Push tokens

class CreatePushTokens < ActiveRecord::Migration[8.0]
  def change
    create_table :push_tokens, id: :uuid do |t|
      t.references :user, null: false, foreign_key: true, type: :uuid
      t.string :token_ciphertext, null: false
      t.string :platform, null: false
      t.boolean :active, default: true, null: false

      t.timestamps
    end

    add_index :push_tokens, [:user_id, :active]
  end
end

Consents

class CreateConsents < ActiveRecord::Migration[8.0]
  def change
    create_table :consents, id: :uuid do |t|
      t.references :user, null: false, foreign_key: true, type: :uuid
      t.string :consent_type, null: false
      t.boolean :granted, null: false
      t.string :ip_address
      t.string :user_agent
      t.datetime :granted_at
      t.datetime :withdrawn_at

      t.timestamps
    end

    add_index :consents, [:user_id, :consent_type]
  end
end

Notification logs

class CreateNotificationLogs < ActiveRecord::Migration[8.0]
  def change
    create_table :notification_logs, id: :uuid do |t|
      t.references :user, null: false, foreign_key: true, type: :uuid
      t.references :reminder_schedule, null: false, foreign_key: true, type: :uuid
      t.datetime :sent_at
      t.string :status, null: false, default: "pending"
      t.string :error_message

      t.timestamps
    end

    add_index :notification_logs, [:user_id, :sent_at]
    add_index :notification_logs, :status
  end
end

Run the migrations:

rails db:migrate

Configuring Lockbox

Generate a Lockbox master key:

rails runner "puts Lockbox.generate_key"

Store the value in .env. Do not commit this key.

LOCKBOX_MASTER_KEY=your_generated_key_here
DATABASE_URL=postgresql://localhost/meditrack_api_development

Then configure Lockbox:

# config/initializers/lockbox.rb
Lockbox.master_key = ENV.fetch("LOCKBOX_MASTER_KEY") do
  raise "LOCKBOX_MASTER_KEY is not configured. Generate one with: rails runner \"puts Lockbox.generate_key\""
end

First model layer

User

class User < ApplicationRecord
  include Discard::Model

  encrypts :email
  blind_index :email

  has_one :user_profile, dependent: :destroy
  has_many :medications, dependent: :destroy
  has_many :consents, dependent: :destroy
  has_many :push_tokens, dependent: :destroy

  validates :email, presence: true, uniqueness: true
  validates :password_digest, presence: true
end

Medication

class Medication < ApplicationRecord
  include Discard::Model

  belongs_to :user
  has_many :reminder_schedules, dependent: :destroy

  encrypts :name, :dosage, :notes

  FREQUENCY_TYPES = %w[daily weekly custom].freeze
  DOSAGE_UNITS = %w[mg ml g units drops].freeze

  validates :name, presence: true
  validates :frequency_type, inclusion: { in: FREQUENCY_TYPES }
  validates :dosage_unit, inclusion: { in: DOSAGE_UNITS }, allow_nil: true

  scope :active, -> { kept.where(active: true) }
end
class Consent < ApplicationRecord
  belongs_to :user

  TYPES = %w[health_data push_notifications terms_of_service].freeze

  validates :consent_type, inclusion: { in: TYPES }
  validates :granted, inclusion: { in: [true, false] }

  scope :active_health_consent, ->(user) {
    where(user: user, consent_type: "health_data", granted: true)
      .where(withdrawn_at: nil)
      .order(created_at: :desc)
      .first
  }
end

CORS configuration

Ionic will run separately during development, usually on port 8100.

# config/initializers/cors.rb
Rails.application.config.middleware.insert_before 0, Rack::Cors do
  allow do
    origins ENV.fetch("ALLOWED_ORIGINS", "http://localhost:8100").split(",")

    resource "/api/*",
      headers: :any,
      methods: [:get, :post, :put, :patch, :delete, :options, :head],
      expose: ["Authorization"],
      max_age: 600
  end
end

In production, ALLOWED_ORIGINS should be set to the real frontend origin instead of a wildcard.

Initial routes

# config/routes.rb
Rails.application.routes.draw do
  namespace :api do
    namespace :v1 do
      devise_for :users,
        path: "auth",
        controllers: {
          registrations: "api/v1/auth/registrations",
          sessions: "api/v1/auth/sessions"
        }

      resources :medications do
        resources :reminder_schedules, shallow: true
      end

      resources :push_tokens, only: [:create, :destroy]

      namespace :users do
        resource :data, only: [:show, :destroy]
        resources :consents, only: [:index, :create, :update]
      end
    end
  end

  get "up", to: proc { [200, {}, ["OK"]] }
end

These routes establish the main API surface:

Technical summary

If you are building a GDPR-compliant medication reminder app, the hard part is not the reminder schedule itself. The hard part is designing the system so that health data is protected by default.

For MediTrack, that means:

This architecture gives us a stronger foundation before we build registration, authentication, push notifications, and the mobile UI.

What comes next

In the next post, we will implement the registration flow with explicit health data consent.

That will include:

MediTrack is still a small app, but it is a useful example because it forces the right engineering question early:

What would change if privacy was part of the data model instead of a paragraph in the footer?

Build in Public

Follow the build

This article is part of my build-in-public series, where I document product thinking, requirements, design decisions, technical tradeoffs, and MVP development.

I'm Erick Marroquin, a mobile and web developer building custom apps, web systems, and automation tools for businesses.

Enjoyed this article? You can support my writing.