Database

pakAG uses MySQL (database name erronka per schema.sql; .env.example documents it as pakag — see Known risks below).

Schema source of truth: schema.sql at the monorepo root.

Tables

addresses

Geocoded delivery addresses. Created by the package creation flow (Google Geocoding API).

ColumnTypeNotes
idINT UNSIGNED AUTO_INCREMENTPK
streetVARCHAR(255)
cityVARCHAR(100)
postal_codeVARCHAR(20)
countryVARCHAR(100)Default 'España'
latitudeDECIMAL(10,7)Geocoded by Google API
longitudeDECIMAL(10,7)Geocoded by Google API
created_atTIMESTAMP

[!NOTE] SQL queries alias latitude/longitude to lat/lng in TypeScript types. Column names in DB differ from field names in frontend types.


users

System users: admins and distributors.

ColumnTypeNotes
idINT UNSIGNED AUTO_INCREMENTPK
nameVARCHAR(150)
emailVARCHAR(255) UNIQUE
password_hashVARCHAR(255)bcrypt
roleENUM('admin','distributor')Default 'distributor'
is_activeBOOLEANDefault TRUE. New users created inactive via the activation flow
created_atTIMESTAMP
updated_atTIMESTAMPAuto-updated on row change

packages

Core domain entity. Represents a parcel to be delivered.

ColumnTypeNotes
idINT UNSIGNED AUTO_INCREMENTPK
tracking_codeVARCHAR(50) UNIQUEShort human-readable code, e.g. PAK-20260001
recipient_nameVARCHAR(150)
recipient_emailVARCHAR(255)Receives status notification emails
weight_kgDECIMAL(6,3)
descriptionTEXT NULLOptional
statusENUM(...)See state machine below
estimated_deliveryDATE NULLSet by route creation; updated when route is optimized
address_idINT UNSIGNEDFK → addresses.id
assigned_toINT UNSIGNED NULLFK → users.id. ON DELETE SET NULL
created_byINT UNSIGNEDFK → users.id
created_atTIMESTAMP
updated_atTIMESTAMP

Package status state machine

pending → assigned → in_transit → delivered
                             ↘ undelivered
                             ↘ failed

Each transition triggers a log entry in package_status_logs and an email to the recipient. See Domain Model for full side-effect table.


tokens

Single table for all token types in the system.

ColumnTypeNotes
idINT UNSIGNED AUTO_INCREMENTPK
tokenVARCHAR(512) UNIQUEOpaque token value (32-byte hex or JWT string)
typeENUM(...)refresh_token, tracking_token, reset_pwd_token, activate_account_token
user_idINT UNSIGNED NULLFK → users.id. Set for refresh_token; ON DELETE CASCADE
package_idINT UNSIGNED NULLFK → packages.id. Set for tracking_token; ON DELETE CASCADE
expires_atTIMESTAMP
revokedBOOLEANDefault FALSE. Revoked on use or logout
created_atTIMESTAMP

Token lifetimes (configured via env):

  • refresh_token: JWT_REFRESH_EXPIRES_DAYS (default 7 days)
  • tracking_token: TRACKING_EXPIRES_DAYS (default 30 days)
  • reset_pwd_token / activate_account_token: 24 hours (hardcoded)

package_status_logs

Full history of every package status change.

ColumnTypeNotes
idINT UNSIGNED AUTO_INCREMENTPK
package_idINT UNSIGNEDFK → packages.id. ON DELETE CASCADE
old_statusENUM NULLNULL for the initial status assignment
new_statusENUM
changed_byINT UNSIGNEDFK → users.id
notesTEXT NULL
changed_atTIMESTAMP

routes

One delivery route per distributor per calendar day (enforced by unique constraint).

ColumnTypeNotes
idINT UNSIGNED AUTO_INCREMENTPK
user_idINT UNSIGNEDFK → users.id (must be distributor)
route_dateDATE
statusENUM('planned','in_progress','completed')Default 'planned'
created_atTIMESTAMP
updated_atTIMESTAMP

Unique constraint: (user_id, route_date) — one route per distributor per day.


route_stops

Individual stops within a route. One stop = one package delivery.

ColumnTypeNotes
idINT UNSIGNED AUTO_INCREMENTPK
route_idINT UNSIGNEDFK → routes.id. ON DELETE CASCADE
package_idINT UNSIGNEDFK → packages.id. ON DELETE CASCADE
stop_orderSMALLINTOptimized by Google Directions API
estimated_arrivalTIME NULLCalculated at route creation
actual_arrivalTIME NULLSet when distributor marks arrival
created_atTIMESTAMP

Unique constraint: (route_id, stop_order) — no two stops share the same order in a route.


Relationships

users ─── packages (assigned_to, created_by)
users ─── routes (user_id)
users ─── package_status_logs (changed_by)
users ─── tokens (user_id)

packages ─── addresses (address_id)
packages ─── route_stops (package_id)
packages ─── package_status_logs (package_id)
packages ─── tokens (package_id, for tracking_token)

routes ─── route_stops (route_id)

Indexes

CREATE INDEX idx_packages_status      ON packages (status);
CREATE INDEX idx_packages_assigned_to ON packages (assigned_to);
CREATE INDEX idx_packages_created_at  ON packages (created_at);
CREATE INDEX idx_tokens_type_expires  ON tokens (type, expires_at, revoked);
CREATE INDEX idx_logs_package_date    ON package_status_logs (package_id, changed_at);
CREATE INDEX idx_routes_date          ON routes (route_date);

SQL conventions

  • All queries use prepared statements (mysql2 ? placeholders). No string concatenation.
  • IDs: INT UNSIGNED AUTO_INCREMENT PRIMARY KEY.
  • Timestamps: TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP.
  • No soft deletes on packages — historical state is preserved in package_status_logs.
  • addresses are not deleted when a package is deleted (Needs verification — no ON DELETE CASCADE on the packages.address_id FK).

Known risks

RiskDetail
DB name inconsistencyschema.sql creates DB as erronka; .env.example documents MYSQL_DATABASE=pakag. Verify which name is used in production before migrating.
No startup validationMYSQL_HOST, MYSQL_USER, MYSQL_PASSWORD, MYSQL_DATABASE env vars can be undefined at startup without an error. The pool fails silently at first query.