5 Commits

Author SHA1 Message Date
Antoni Nuñez Romeu 8615e213de PIP PLATFORM ended
Run Tests on Branches / Backend Tests (push) Successful in 3m28s
Run Tests on Branches / Frontend Tests (push) Successful in 3m25s
Run Tests on Branches / PIP Tests (push) Failing after 9s
2026-07-01 17:19:03 +02:00
Antoni Nuñez Romeu c5df0ec0b2 First implementation API for PIP (Proyecto: Pharmacy Integration Platform)
Run Tests on Branches / Backend Tests (push) Successful in 3m32s
Run Tests on Branches / Frontend Tests (push) Successful in 3m27s
2026-07-01 16:46:52 +02:00
Antoni Nuñez Romeu d935db4b3e Android branch testing
Run Tests on Branches / Backend Tests (push) Successful in 3m31s
Run Tests on Branches / Frontend Tests (push) Successful in 3m26s
2026-07-01 16:44:48 +02:00
Antoni Nuñez Romeu 1255bda9c7 Testing steps to avoid wrong implementation
Run Tests on Branches / Backend Tests (push) Successful in 4m41s
Run Tests on Branches / Frontend Tests (push) Successful in 3m46s
2026-07-01 16:36:47 +02:00
Antoni Nuñez Romeu 1c31362e0b Rebranding app and naming 2026-07-01 15:54:10 +02:00
181 changed files with 10075 additions and 329 deletions
+67
View File
@@ -0,0 +1,67 @@
name: Run Tests on Branches
on:
push:
branches-ignore:
- 'main'
jobs:
test-backend:
name: Backend Tests
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: backend/package-lock.json
- name: Install Backend Dependencies
run: cd backend && npm ci
- name: Run Backend Tests
run: cd backend && npm test -- --ci
test-frontend:
name: Frontend Tests
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- name: Install Frontend Dependencies
run: cd frontend && npm ci
- name: Run Frontend Tests
run: cd frontend && npm test -- --run --reporter=basic
test-pip:
name: PIP Tests
runs-on: ubuntu-latest
steps:
- name: Checkout Code
uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '20'
cache: 'npm'
cache-dependency-path: pip-platform/package-lock.json
- name: Install PIP dependences
run: cd pip-platform && npm ci
- name: Run PIP Tests
run: cd pip-platform && npm test -- --run --reporter=basic
+1 -1
View File
@@ -1,5 +1,5 @@
/**
* FarmaFinder external pharmacy sources (OpenStreetMap, Google Places, open-data URLs).
* FarmaClic external pharmacy sources (OpenStreetMap, Google Places, open-data URLs).
* Used by the backend admin import; OSM does not require n8n.
*/
+1 -1
View File
@@ -83,7 +83,7 @@ export async function fetchPharmaciesFromOpenDataUrl(openDataUrl) {
const res = await fetch(u, {
headers: {
Accept: 'application/json',
'User-Agent': 'FarmaFinder/1.0 (open-data pharmacy import)',
'User-Agent': 'FarmaClic/1.0 (open-data pharmacy import)',
},
});
const text = await res.text();
+1 -1
View File
@@ -1,5 +1,5 @@
/**
* Parse a subset of the OSM opening_hours format into FarmaFinder's internal
* Parse a subset of the OSM opening_hours format into FarmaClic's internal
* shape: { mon: ["09:00", "21:00"], ..., sun: null }.
*
* Supports the patterns commonly seen on amenity=pharmacy nodes:
+1 -1
View File
@@ -35,7 +35,7 @@ async function fetchOverpass(endpoint, body) {
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Accept: 'application/json',
'User-Agent': 'FarmaFinder/1.0 (OSM pharmacy import; local admin)',
'User-Agent': 'FarmaClic/1.0 (OSM pharmacy import; local admin)',
},
body,
});
+1 -1
View File
@@ -1,5 +1,5 @@
{
"name": "farma-finder-api-sources",
"name": "farma-clic-api-sources",
"private": true,
"type": "module"
}
+3
View File
@@ -0,0 +1,3 @@
# Default ignored files
/shelf/
/workspace.xml
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AndroidProjectSystem">
<option name="providerId" value="com.android.tools.idea.GradleProjectSystem" />
</component>
</project>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="21" />
</component>
</project>
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="deploymentTargetSelector">
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
<DialogSelection />
</SelectionState>
</selectionStates>
</component>
</project>
+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DeviceTable">
<option name="columnSorters">
<list>
<ColumnSorterState>
<option name="column" value="Name" />
<option name="order" value="ASCENDING" />
</ColumnSorterState>
</list>
</option>
</component>
</project>
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectMigrations">
<option name="MigrateToGradleLocalJavaHome">
<set>
<option value="$PROJECT_DIR$" />
</set>
</option>
</component>
</project>
+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="jbr-21" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">
<option name="id" value="Android" />
</component>
</project>
+13
View File
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="PlanningModeManager">
<option name="approvalStates">
<map>
<entry key="20260630-090134-081bb55a-1053-43ab-950d-e13c6adcdde9" value="true" />
<entry key="20260630-090415-b044c503-d35a-4112-8a57-02da5e73f9d9" value="true" />
<entry key="20260630-090515-c7bb6ec8-96fa-4399-98d4-5e18af72de07" value="true" />
<entry key="20260630-090813-d485fedc-ebae-4bad-85e8-8c07f2842ed0" value="true" />
</map>
</option>
</component>
</project>
+17
View File
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RunConfigurationProducerService">
<option name="ignoredProducers">
<set>
<option value="com.intellij.execution.junit.AbstractAllInDirectoryConfigurationProducer" />
<option value="com.intellij.execution.junit.AllInPackageConfigurationProducer" />
<option value="com.intellij.execution.junit.PatternConfigurationProducer" />
<option value="com.intellij.execution.junit.TestInClassConfigurationProducer" />
<option value="com.intellij.execution.junit.UniqueIdConfigurationProducer" />
<option value="com.intellij.execution.junit.testDiscovery.JUnitTestDiscoveryConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinJUnitRunConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinPatternConfigurationProducer" />
</set>
</option>
</component>
</project>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="StudioBotProjectSettings">
<option name="shareContext" value="OptedIn" />
</component>
</project>
+4 -10
View File
@@ -7,10 +7,10 @@ if (keystorePropertiesFile.exists()) {
}
android {
namespace "net.hacecalor.farmafinder"
namespace "net.hacecalor.farmaclic"
compileSdk rootProject.ext.compileSdkVersion
defaultConfig {
applicationId "net.hacecalor.farmafinder"
applicationId "net.hacecalor.farmaclic"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
@@ -36,7 +36,7 @@ android {
release {
minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
if (keystorePropertiesFile.exists()) {
signingConfig signingConfigs.release
}
@@ -44,14 +44,8 @@ android {
}
}
repositories {
flatDir{
dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
}
}
dependencies {
implementation fileTree(include: ['*.jar'], dir: 'libs')
implementation fileTree(include: ['*.jar', '*.aar'], dir: 'libs')
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
@@ -1,4 +1,4 @@
package net.hacecalor.farmafinder;
package net.hacecalor.farmaclic;
import com.getcapacitor.BridgeActivity;
+4 -4
View File
@@ -1,7 +1,7 @@
<?xml version='1.0' encoding='utf-8'?>
<resources>
<string name="app_name">FarmaFinder</string>
<string name="title_activity_main">FarmaFinder</string>
<string name="package_name">net.hacecalor.farmafinder</string>
<string name="custom_url_scheme">net.hacecalor.farmafinder</string>
<string name="app_name">FarmaClic</string>
<string name="title_activity_main">FarmaClic</string>
<string name="package_name">net.hacecalor.farmaclic</string>
<string name="custom_url_scheme">net.hacecalor.farmaclic</string>
</resources>
+1 -1
View File
@@ -7,7 +7,7 @@ buildscript {
mavenCentral()
}
dependencies {
classpath 'com.android.tools.build:gradle:8.13.0'
classpath 'com.android.tools.build:gradle:9.2.1'
classpath 'com.google.gms:google-services:4.4.4'
// NOTE: Do not place your application dependencies here; they belong
+5
View File
@@ -20,3 +20,8 @@ org.gradle.jvmargs=-Xmx1536m
# Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
android.defaults.buildfeatures.resvalues=false
android.uniquePackageNames=false
android.dependency.useConstraints=true
android.r8.strictFullModeForKeepRules=false
android.newDsl=true
@@ -0,0 +1,13 @@
#This file is generated by updateDaemonJvm
toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/491f83666ae7f4d6ebb28fee72ebb035/redirect
toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/0d1a1acdc708062093673f65aa9aba4b/redirect
toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/491f83666ae7f4d6ebb28fee72ebb035/redirect
toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/0d1a1acdc708062093673f65aa9aba4b/redirect
toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/7083b89563e7ce20943037b8cd2b8cc2/redirect
toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/060bbb778a1f55ea705fdebd2ccfeab9/redirect
toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/491f83666ae7f4d6ebb28fee72ebb035/redirect
toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/0d1a1acdc708062093673f65aa9aba4b/redirect
toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/d09679dc60fe5aa05ef7d03efdefac20/redirect
toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/ed4e3bf2f5e7c5d9aabc4cbd8acd555e/redirect
toolchainVendor=JETBRAINS
toolchainVersion=21
Binary file not shown.
+3 -2
View File
@@ -1,8 +1,9 @@
#Fri Jun 26 12:32:52 CEST 2026
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.0-bin.zip
networkTimeout=10000
retries=0
retryBackOffMs=500
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
+16 -16
View File
@@ -1,7 +1,7 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
@@ -15,10 +15,12 @@
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
# gradlew start up script for POSIX generated by Gradle.
#
# Important for running:
#
@@ -27,7 +29,7 @@
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
# ksh gradlew
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
@@ -55,7 +57,7 @@
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
@@ -83,7 +85,8 @@ done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
@@ -111,7 +114,6 @@ case "$( uname )" in #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
@@ -144,7 +146,7 @@ if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC3045
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
@@ -152,7 +154,7 @@ if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC3045
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
@@ -169,7 +171,6 @@ fi
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
@@ -201,16 +202,15 @@ fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command;
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
# shell script including quotes and variable substitutions, so put them in
# double quotes to make sure that they get re-expanded; and
# * put everything else in single quotes, so that it's not re-expanded.
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
+24 -34
View File
@@ -13,16 +13,18 @@
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem gradlew startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
@rem Set local scope for the variables, and ensure extensions are enabled
setlocal EnableExtensions
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@@ -43,13 +45,13 @@ set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo.
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
"%COMSPEC%" /c exit 1
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
@@ -57,36 +59,24 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo.
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
echo.
echo Please set the JAVA_HOME variable in your environment to match the
echo location of your Java installation.
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
"%COMSPEC%" /c exit 1
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
@rem Execute gradlew
@rem endlocal doesn't take effect until after the line is parsed and variables are expanded
@rem which allows us to clear the local environment before executing the java command
endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
:exitWithErrorLevel
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
"%COMSPEC%" /c exit %ERRORLEVEL%
+3
View File
@@ -1,3 +1,6 @@
plugins {
id 'org.gradle.toolchains.foojay-resolver-convention' version '1.0.0'
}
include ':app'
include ':capacitor-cordova-android-plugins'
project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/')
+1 -1
View File
@@ -7,7 +7,7 @@ REDIS_PORT=6379
REDIS_PASSWORD=
# PostgreSQL for user accounts (leave unset to fallback to SQLite — dev/test only)
PG_URL=postgresql://farmafinder:change-me@localhost:5432/farmafinder
PG_URL=postgresql://farmaclic:change-me@localhost:5432/farmaclic
PG_PASSWORD=change-me
# Web Push (VAPID). Generate with:
+1 -1
View File
@@ -1,5 +1,5 @@
/**
* Fetch pharmacy lists from an n8n (or any) HTTP webhook and map into FarmaFinder rows.
* Fetch pharmacy lists from an n8n (or any) HTTP webhook and map into FarmaClic rows.
* Default URL: FARMACIAS_WEBHOOK_URL env or the project webhook.
*/
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "farma-finder-backend",
"name": "farma-clic-backend",
"version": "1.0.0",
"description": "Backend API for FarmaFinder",
"description": "Backend API for FarmaClic",
"main": "server.js",
"type": "module",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
#!/bin/bash
echo "🔄 FarmaFinder - Quick Database Reset"
echo "🔄 FarmaClic - Quick Database Reset"
echo "===================================="
echo ""
echo "Este script eliminará la base de datos actual y creará una nueva."
+3 -3
View File
@@ -30,7 +30,7 @@ const PORT = process.env.PORT || 3001;
// span_id to every log line so they can be correlated in Grafana.
const logger = pino({
level: process.env.LOG_LEVEL || 'info',
base: { service: process.env.OTEL_SERVICE_NAME || 'farmafinder-backend' },
base: { service: process.env.OTEL_SERVICE_NAME || 'farmaclic-backend' },
});
// Trust the reverse proxy in front of us (Docker/Traefik/nginx/Cloudflare) so
@@ -61,7 +61,7 @@ if (PG_URL) {
}
const sessionConfig = {
secret: process.env.SESSION_SECRET || 'farma-finder-secret-key-change-in-production',
secret: process.env.SESSION_SECRET || 'farma-clic-secret-key-change-in-production',
resave: false,
saveUninitialized: false,
cookie: {
@@ -703,7 +703,7 @@ async function nominatimSearchFirstHit(originalQuery) {
Accept: 'application/json',
'Accept-Language': 'es,en',
'User-Agent':
'FarmaFinder/1.0 (pharmacy admin; geocoding; https://github.com/)',
'FarmaClic/1.0 (pharmacy admin; geocoding; https://github.com/)',
},
});
const text = await nomRes.text();
+5 -5
View File
@@ -1,8 +1,8 @@
// OpenTelemetry Node SDK bootstrap for FarmaFinder backend.
// OpenTelemetry Node SDK bootstrap for FarmaClic backend.
// Started as a side-effect import from server.js (ESM).
//
// Env vars (set by docker-compose):
// OTEL_SERVICE_NAME — default: farmafinder-backend
// OTEL_SERVICE_NAME — default: farmaclic-backend
// OTEL_EXPORTER_OTLP_ENDPOINT — OTLP gRPC endpoint (e.g. http://alloy:4317)
//
// Exports traces to the shared Grafana Alloy collector, where they are
@@ -15,17 +15,17 @@ import * as resources from '@opentelemetry/resources';
import { ATTR_SERVICE_NAME, ATTR_SERVICE_NAMESPACE } from '@opentelemetry/semantic-conventions';
import { PinoInstrumentation } from '@opentelemetry/instrumentation-pino';
const serviceName = process.env.OTEL_SERVICE_NAME || 'farmafinder-backend';
const serviceName = process.env.OTEL_SERVICE_NAME || 'farmaclic-backend';
const otlpEndpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT || 'http://localhost:4317';
const resource = typeof resources.resourceFromAttributes === 'function'
? resources.resourceFromAttributes({
[ATTR_SERVICE_NAME]: serviceName,
[ATTR_SERVICE_NAMESPACE]: 'farmafinder',
[ATTR_SERVICE_NAMESPACE]: 'farmaclic',
})
: new resources.Resource({
[ATTR_SERVICE_NAME]: serviceName,
[ATTR_SERVICE_NAMESPACE]: 'farmafinder',
[ATTR_SERVICE_NAMESPACE]: 'farmaclic',
});
const sdk = new NodeSDK({
+2 -2
View File
@@ -1,6 +1,6 @@
{
"appId": "net.hacecalor.farmafinder",
"appName": "FarmaFinder",
"appId": "net.hacecalor.farmaclic",
"appName": "FarmaClic",
"webDir": "frontend/dist",
"server": {
"androidScheme": "https"
+10 -10
View File
@@ -7,14 +7,14 @@ services:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: farmafinder
POSTGRES_USER: farmafinder
POSTGRES_DB: farmaclic
POSTGRES_USER: farmaclic
POSTGRES_PASSWORD: ${PG_PASSWORD:-change-me-in-production}
volumes:
- postgres_data:/var/lib/postgresql/data
backend:
image: git.hacecalor.net/ichitux/farmafinder-backend:latest
image: git.hacecalor.net/ichitux/farmaclic-backend:latest
build:
context: .
dockerfile: backend/Dockerfile
@@ -25,19 +25,19 @@ services:
PORT: "3001"
NODE_ENV: production
SESSION_SECRET: ${SESSION_SECRET:-change-me-in-production}
CORS_ORIGIN: http://localhost:3000
CORS_ORIGIN: http://localhost:4000
REDIS_HOST: redis
REDIS_PORT: "6379"
REDIS_PASSWORD: ${REDIS_PASSWORD:-}
DATABASE_PATH: /app/data/database.sqlite
FARMACIAS_WEBHOOK_URL: ${FARMACIAS_WEBHOOK_URL:-}
PG_URL: postgresql://farmafinder:${PG_PASSWORD:-change-me-in-production}@postgres:5432/farmafinder
PG_URL: postgresql://farmaclic:${PG_PASSWORD:-change-me-in-production}@postgres:5432/farmaclic
# OpenTelemetry — exported via OTLP gRPC to the shared Alloy collector
OTEL_SERVICE_NAME: farmafinder-backend
OTEL_SERVICE_NAME: farmaclic-backend
OTEL_EXPORTER_OTLP_ENDPOINT: http://host.docker.internal:4317
OTEL_TRACES_EXPORTER: otlp
OTEL_LOGS_EXPORTER: otlp
OTEL_RESOURCE_ATTRIBUTES: service.namespace=farmafinder
OTEL_RESOURCE_ATTRIBUTES: service.namespace=farmaclic
volumes:
- backend_data:/app/data
depends_on:
@@ -45,17 +45,17 @@ services:
- postgres
frontend:
image: git.hacecalor.net/ichitux/farmafinder-frontend:latest
image: git.hacecalor.net/ichitux/farmaclic-frontend:latest
build:
context: ./frontend
args:
VITE_FARO_ENDPOINT: ${VITE_FARO_ENDPOINT:-http://localhost:4318}
VITE_FARO_APP_NAME: ${VITE_FARO_APP_NAME:-farmafinder-frontend}
VITE_FARO_APP_NAME: ${VITE_FARO_APP_NAME:-farmaclic-frontend}
VITE_FARO_ENV: ${VITE_FARO_ENV:-production}
VITE_FARO_APP_VERSION: ${VITE_FARO_APP_VERSION:-1.0.0}
restart: unless-stopped
ports:
- "3000:80"
- "4000:80"
depends_on:
- backend
+1 -1
View File
@@ -9,7 +9,7 @@
VITE_FARO_ENDPOINT=http://localhost:4318
# App name shown in Grafana Explore / Faro data source.
VITE_FARO_APP_NAME=farmafinder-frontend
VITE_FARO_APP_NAME=farmaclic-frontend
# Environment label (production | staging | development).
VITE_FARO_ENV=production
+1 -1
View File
@@ -1,6 +1,6 @@
FROM node:20-alpine AS build
ARG VITE_FARO_ENDPOINT
ARG VITE_FARO_APP_NAME=farmafinder-frontend
ARG VITE_FARO_APP_NAME=farmaclic-frontend
ARG VITE_FARO_ENV=production
ARG VITE_FARO_APP_VERSION=1.0.0
ENV VITE_FARO_ENDPOINT=$VITE_FARO_ENDPOINT
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#00450d" />
<title>FarmaFinder</title>
<title>FarmaClic</title>
<link rel="icon" href="/favicon.png" type="image/png" />
<link rel="icon" href="/favicon.ico" sizes="32x32" />
<link rel="apple-touch-icon" href="/logo.png" />
+1 -1
View File
@@ -1,5 +1,5 @@
{
"name": "farma-finder-frontend",
"name": "farma-clic-frontend",
"version": "1.0.0",
"type": "module",
"scripts": {
+1 -1
View File
@@ -64,7 +64,7 @@ function App() {
}
let activeView;
let topBarTitle = 'FarmaFinder';
let topBarTitle = 'FarmaClic';
let showBack = false;
switch (screen) {
+15 -15
View File
@@ -25,7 +25,7 @@ function LoginModal({ onLogin, onClose, initialMode = 'login' }) {
const u = username.trim();
if (!u || !password) return;
if (mode === 'register' && password.length < 8) {
setError('Password must be at least 8 characters');
setError('La contraseña debe tener al menos 8 caracteres');
return;
}
setLoading(true);
@@ -40,12 +40,12 @@ function LoginModal({ onLogin, onClose, initialMode = 'login' }) {
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setError(data.error || (mode === 'register' ? 'Could not create account' : 'Login failed'));
setError(data.error || (mode === 'register' ? 'No se pudo crear la cuenta' : 'Inicio de sesión fallido'));
} else {
onLogin(data.user);
}
} catch {
setError('Network error — try again');
setError('Error de red — inténtalo de nuevo');
} finally {
setLoading(false);
}
@@ -71,7 +71,7 @@ function LoginModal({ onLogin, onClose, initialMode = 'login' }) {
onClick={() => setMode('login')}
disabled={loading}
>
Sign in
Iniciar sesión
</button>
<button
type="button"
@@ -81,20 +81,20 @@ function LoginModal({ onLogin, onClose, initialMode = 'login' }) {
onClick={() => setMode('register')}
disabled={loading}
>
Create account
Crear cuenta
</button>
</div>
<h2 id="modal-title">{isRegister ? 'Create your account' : 'Welcome back'}</h2>
<h2 id="modal-title">{isRegister ? 'Crea tu cuenta' : 'Bienvenido de nuevo'}</h2>
<p className="modal-sub">
{isRegister
? 'Save your address and get notified when medicines arrive.'
: 'Sign in to manage your profile and notifications.'}
? 'Guarda tu dirección y recibe notificaciones cuando lleguen medicamentos.'
: 'Inicia sesión para gestionar tu perfil y notificaciones.'}
</p>
<form onSubmit={handleSubmit} noValidate>
<div className="modal-field">
<label htmlFor="modal-username">Username</label>
<label htmlFor="modal-username">Usuario</label>
<input
id="modal-username"
type="text"
@@ -107,11 +107,11 @@ function LoginModal({ onLogin, onClose, initialMode = 'login' }) {
maxLength={isRegister ? 32 : undefined}
/>
{isRegister && (
<p className="modal-hint">332 characters: letters, digits, or underscore.</p>
<p className="modal-hint">332 caracteres: letras, dígitos o guión bajo.</p>
)}
</div>
<div className="modal-field">
<label htmlFor="modal-password">Password</label>
<label htmlFor="modal-password">Contraseña</label>
<input
id="modal-password"
type="password"
@@ -122,7 +122,7 @@ function LoginModal({ onLogin, onClose, initialMode = 'login' }) {
minLength={isRegister ? 8 : undefined}
/>
{isRegister && (
<p className="modal-hint">At least 8 characters.</p>
<p className="modal-hint">Al menos 8 caracteres.</p>
)}
</div>
{error && <p className="modal-error">{error}</p>}
@@ -133,7 +133,7 @@ function LoginModal({ onLogin, onClose, initialMode = 'login' }) {
onClick={onClose}
disabled={loading}
>
Cancel
Cancelar
</button>
<button
type="submit"
@@ -141,8 +141,8 @@ function LoginModal({ onLogin, onClose, initialMode = 'login' }) {
disabled={loading || !username.trim() || !password}
>
{loading
? (isRegister ? 'Creating…' : 'Signing in…')
: (isRegister ? 'Create account' : 'Sign in')}
? (isRegister ? 'Creando…' : 'Iniciando sesión…')
: (isRegister ? 'Crear cuenta' : 'Iniciar sesión')}
</button>
</div>
</form>
+12 -12
View File
@@ -11,7 +11,7 @@ function MedicineResults({ medicines, onSelect, query, currentUser, onLoginReque
if (medicines.length === 0 && query.length >= 2) {
return (
<div className="no-results">
<p>No medicines found matching "{query}"</p>
<p>No se encontraron medicamentos para "{query}"</p>
</div>
);
}
@@ -49,7 +49,7 @@ function MedicineCard({ medicine, onSelect, currentUser, onLoginRequest }) {
return;
}
if (!supported) {
setError('Notifications need iOS 16.4+ and this site installed as an app (Share → Add to Home Screen).');
setError('Las notificaciones requieren iOS 16.4+ y este sitio instalado como app (Compartir → Añadir a Pantalla de Inicio).');
return;
}
if (busy) return;
@@ -65,7 +65,7 @@ function MedicineCard({ medicine, onSelect, currentUser, onLoginRequest }) {
}
} catch (err) {
console.error('[notify] toggle failed:', err);
setError(err.message || 'Could not update subscription');
setError(err.message || 'No se pudo actualizar la suscripción');
} finally {
setBusy(false);
}
@@ -83,29 +83,29 @@ function MedicineCard({ medicine, onSelect, currentUser, onLoginRequest }) {
aria-pressed={subscribed && !!currentUser}
aria-label={
!currentUser
? 'Login to enable notifications'
? 'Inicia sesión para activar notificaciones'
: subscribed
? 'Stop notifications for this medicine'
: 'Notify me when this medicine becomes available'
? 'Desactivar notificaciones para este medicamento'
: 'Notificarme cuando esté disponible'
}
title={
!currentUser
? 'Login to enable notifications'
? 'Inicia sesión para activar notificaciones'
: subscribed
? 'Notifications on — click to turn off'
: 'Notify me when this medicine is added to a pharmacy'
? 'Notificaciones activadas — clic para desactivar'
: 'Notificarme cuando este medicamento esté en una farmacia'
}
>
{subscribed && currentUser ? '🔔' : '🔕'}
</button>
</div>
<div className="medicine-card-body">
<p><strong>Active Ingredient:</strong> {medicine.active_ingredient}</p>
<p><strong>Dosage:</strong> {medicine.dosage} <strong>Form:</strong> {medicine.form}</p>
<p><strong>Principio Activo:</strong> {medicine.active_ingredient}</p>
<p><strong>Dosis:</strong> {medicine.dosage} <strong>Forma:</strong> {medicine.form}</p>
{error && <p className="notify-error" onClick={e => e.stopPropagation()}>{error}</p>}
</div>
<div className="medicine-card-footer">
<span className="view-pharmacies">View pharmacies </span>
<span className="view-pharmacies">Ver farmacias </span>
</div>
</div>
);
+18 -18
View File
@@ -13,7 +13,7 @@ function PharmacyList({ pharmacies, loading, userPosition, medicine, currentUser
if (loading) {
return (
<div className="loading-pharmacies">
<p>Loading pharmacies...</p>
<p>Cargando farmacias...</p>
</div>
);
}
@@ -21,7 +21,7 @@ function PharmacyList({ pharmacies, loading, userPosition, medicine, currentUser
if (pharmacies.length === 0) {
return (
<div className="no-pharmacies">
<p>No pharmacies found selling this medicine</p>
<p>No se encontraron farmacias con este medicamento</p>
</div>
);
}
@@ -29,7 +29,7 @@ function PharmacyList({ pharmacies, loading, userPosition, medicine, currentUser
return (
<div className="pharmacy-list">
<h3 className="pharmacy-list-title">
Available at {pharmacies.length} {pharmacies.length === 1 ? 'pharmacy' : 'pharmacies'}
Disponible en {pharmacies.length} {pharmacies.length === 1 ? 'farmacia' : 'farmacias'}
</h3>
<div className="pharmacy-grid">
{pharmacies.map((pharmacy) => {
@@ -76,7 +76,7 @@ function PharmacyCard({ pharmacy, distanceKm, medicine, currentUser, onLoginRequ
return;
}
if (!supported) {
setError('Notifications need iOS 16.4+ and this site installed as an app (Share → Add to Home Screen).');
setError('Las notificaciones requieren iOS 16.4+ y este sitio instalado como app (Compartir → Añadir a Pantalla de Inicio).');
return;
}
if (busy) return;
@@ -92,7 +92,7 @@ function PharmacyCard({ pharmacy, distanceKm, medicine, currentUser, onLoginRequ
}
} catch (err) {
console.error('[notify] pharmacy toggle failed:', err);
setError(err.message || 'Could not update subscription');
setError(err.message || 'No se pudo actualizar la suscripción');
} finally {
setBusy(false);
}
@@ -116,18 +116,18 @@ function PharmacyCard({ pharmacy, distanceKm, medicine, currentUser, onLoginRequ
disabled={busy}
aria-pressed={subscribed && !!currentUser}
aria-label={
!currentUser
? 'Login to enable notifications'
: subscribed
? 'Stop notifications for this pharmacy'
: 'Notify me when this medicine arrives at this pharmacy'
}
title={
!currentUser
? 'Login to enable notifications'
: subscribed
? 'Notifications on for this pharmacy — click to turn off'
: 'Notify me when this medicine arrives at this pharmacy'
!currentUser
? 'Inicia sesión para activar notificaciones'
: subscribed
? 'Desactivar notificaciones para esta farmacia'
: 'Notificarme cuando llegue a esta farmacia'
}
title={
!currentUser
? 'Inicia sesión para activar notificaciones'
: subscribed
? 'Notificaciones activadas para esta farmacia — clic para desactivar'
: 'Notificarme cuando llegue a esta farmacia'
}
>
{subscribed && currentUser ? '🔔' : '🔕'}
@@ -152,7 +152,7 @@ function PharmacyCard({ pharmacy, distanceKm, medicine, currentUser, onLoginRequ
)}
{pharmacy.stock !== undefined && (
<span className={`stock ${pharmacy.stock > 20 ? 'in-stock' : pharmacy.stock > 0 ? 'low-stock' : 'out-of-stock'}`}>
{pharmacy.stock > 20 ? '✓ In Stock' : pharmacy.stock > 0 ? ` Low Stock (${pharmacy.stock})` : '✗ Out of Stock'}
{pharmacy.stock > 20 ? '✓ En Stock' : pharmacy.stock > 0 ? `⚠ Stock Bajo (${pharmacy.stock})` : '✗ Sin Stock'}
</span>
)}
</div>
+10 -10
View File
@@ -21,7 +21,7 @@ function SavedNotifications({ onClose }) {
].sort((a, b) => (b.created_at || '').localeCompare(a.created_at || ''));
setItems(merged);
} catch (err) {
if (!cancelled) setError(err.message || 'Could not load saved notifications');
if (!cancelled) setError(err.message || 'No se pudieron cargar las notificaciones guardadas');
} finally {
if (!cancelled) setLoading(false);
}
@@ -42,7 +42,7 @@ function SavedNotifications({ onClose }) {
if (!res.ok && res.status !== 204) throw new Error(`HTTP ${res.status}`);
setItems(prev => prev.filter(i => !(i.scope === item.scope && i.id === item.id)));
} catch (err) {
setError(err.message || 'Could not remove notification');
setError(err.message || 'No se pudo eliminar la notificación');
} finally {
setBusyId(null);
}
@@ -52,15 +52,15 @@ function SavedNotifications({ onClose }) {
<div className="saved-notifications-backdrop" onClick={onClose}>
<div className="saved-notifications-modal" onClick={e => e.stopPropagation()}>
<div className="saved-notifications-header">
<h2>🔔 Saved Notifications</h2>
<button className="saved-notifications-close" onClick={onClose} aria-label="Close">×</button>
<h2>🔔 Notificaciones Guardadas</h2>
<button className="saved-notifications-close" onClick={onClose} aria-label="Cerrar">×</button>
</div>
<div className="saved-notifications-body">
{loading && <p className="saved-notifications-status">Loading</p>}
{loading && <p className="saved-notifications-status">Cargando</p>}
{!loading && error && <p className="saved-notifications-error">{error}</p>}
{!loading && !error && items.length === 0 && (
<p className="saved-notifications-empty">
No saved notifications yet. Tap the 🔕 bell on an out-of-stock pharmacy to get notified when it's restocked.
Aún no hay notificaciones guardadas. Toca la campana 🔕 en una farmacia sin stock para recibir notificaciones cuando se reponga.
</p>
)}
{!loading && !error && items.length > 0 && (
@@ -77,7 +77,7 @@ function SavedNotifications({ onClose }) {
{item.scope === 'pharmacy' ? (
<>
<span className="saved-notifications-chip saved-notifications-chip--pharmacy">
🏥 {item.pharmacy_name || `Pharmacy #${item.pharmacy_id}`}
🏥 {item.pharmacy_name || `Farmacia #${item.pharmacy_id}`}
</span>
{item.pharmacy_address && (
<span className="saved-notifications-address">{item.pharmacy_address}</span>
@@ -85,7 +85,7 @@ function SavedNotifications({ onClose }) {
</>
) : (
<span className="saved-notifications-chip">
Any pharmacy
Cualquier farmacia
</span>
)}
</div>
@@ -95,9 +95,9 @@ function SavedNotifications({ onClose }) {
className="saved-notifications-remove"
onClick={() => handleDelete(item)}
disabled={busyId === key}
aria-label="Remove notification"
aria-label="Eliminar notificación"
>
{busyId === key ? '' : 'Remove'}
{busyId === key ? '…' : 'Eliminar'}
</button>
</li>
);
+1 -1
View File
@@ -17,7 +17,7 @@ function TopBar({ title, showBack, onBack, rightAction }) {
{showBack ? (
<h1 className="topbar-title">{title}</h1>
) : (
<img src="/logo.png" alt="FarmaFinder" className="topbar-logo-img" />
<img src="/logo.png" alt="FarmaClic" className="topbar-logo-img" />
)}
<div className="topbar-right">{rightAction || <div className="topbar-spacer" />}</div>
</div>
+11 -11
View File
@@ -25,14 +25,14 @@ function LoginForm({ onLogin }) {
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Login failed');
throw new Error(data.error || 'Inicio de sesión fallido');
}
// Success - notify parent component
onLogin(data.user);
} catch (error) {
console.error('Login error:', error);
setError(error.message || 'Invalid username or password');
setError(error.message || 'Usuario o contraseña inválidos');
} finally {
setLoading(false);
}
@@ -42,8 +42,8 @@ function LoginForm({ onLogin }) {
<div className="login-container">
<div className="login-box">
<div className="login-header">
<h2>🔐 Admin Login</h2>
<p>Please enter your credentials to access the admin panel</p>
<h2>🔐 Acceso Administración</h2>
<p>Introduce tus credenciales para acceder al panel de administración</p>
</div>
<form onSubmit={handleSubmit} className="login-form">
@@ -54,13 +54,13 @@ function LoginForm({ onLogin }) {
)}
<div className="form-group">
<label htmlFor="username">Username</label>
<label htmlFor="username">Usuario</label>
<input
id="username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="Enter username"
placeholder="Introduce usuario"
required
autoFocus
disabled={loading}
@@ -68,13 +68,13 @@ function LoginForm({ onLogin }) {
</div>
<div className="form-group">
<label htmlFor="password">Password</label>
<label htmlFor="password">Contraseña</label>
<input
id="password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Enter password"
placeholder="Introduce contraseña"
required
disabled={loading}
/>
@@ -85,16 +85,16 @@ function LoginForm({ onLogin }) {
className="login-button"
disabled={loading || !username || !password}
>
{loading ? 'Logging in...' : 'Login'}
{loading ? 'Iniciando sesión...' : 'Iniciar sesión'}
</button>
</form>
<div className="login-footer">
<p className="help-text">
Default credentials: <code>admin</code> / <code>admin123</code>
Credenciales por defecto: <code>admin</code> / <code>admin123</code>
</p>
<p className="warning-text">
Change the default password after first login!
¡Cambia la contraseña por defecto tras el primer inicio de sesión!
</p>
</div>
</div>
@@ -29,7 +29,7 @@ function MedicineManagement() {
} catch (error) {
if (error.name === 'AbortError') return;
console.error('Error searching medicines:', error);
alert('Error searching medicines from CIMA API');
alert('Error al buscar medicamentos en la API CIMA');
} finally {
if (!controller.signal.aborted) setLoading(false);
}
@@ -44,7 +44,7 @@ function MedicineManagement() {
return (
<div className="admin-section">
<div className="section-header">
<h2>Search Medicines (CIMA API)</h2>
<h2>Buscar Medicamentos (API CIMA)</h2>
</div>
<div className="info-box">
@@ -54,7 +54,7 @@ function MedicineManagement() {
<div className="admin-form">
<div className="form-group">
<label>Search for medicines</label>
<label>Buscar medicamentos</label>
<input
type="text"
value={searchQuery}
@@ -64,11 +64,11 @@ function MedicineManagement() {
</div>
</div>
{loading && <div className="loading">Searching CIMA API...</div>}
{loading && <div className="loading">Buscando en API CIMA...</div>}
{!loading && medicines.length > 0 && (
<div className="admin-list">
<p className="info-text">Found {medicines.length} medicines</p>
<p className="info-text">Se encontraron {medicines.length} medicamentos</p>
{medicines.map((medicine) => (
<div key={medicine.nregistro} className="admin-item">
<div className="item-content">
@@ -56,10 +56,10 @@ function haversineMeters(lat1, lon1, lat2, lon2) {
}
const REGION_PRESETS = [
{ id: 'custom', label: 'Custom coordinates', lat: '', lon: '', radio: '' },
{ id: 'custom', label: 'Coordenadas personalizadas', lat: '', lon: '', radio: '' },
{
id: 'rubi',
label: 'Example: Rubí area (1.5 km)',
label: 'Ejemplo: Área de Rubí (1.5 km)',
lat: '41.5631',
lon: '2.0038',
radio: '1500',
@@ -76,16 +76,16 @@ async function geocodeErrorMessage(response) {
}
if (typeof body.error === 'string' && body.error.trim()) return body.error;
if (response.status === 401) {
return 'Session expired or not logged in. Sign in again on Admin, then retry.';
return 'Sesn expirada o no has iniciado sesión. Inicia sesión de nuevo en el panel Admin y reintenta.';
}
if (response.status === 404) {
const looksLikeHtml = /<!DOCTYPE|<html[\s>]/i.test(text || '');
if (looksLikeHtml) {
return 'The app could not reach the API (404). Use http://localhost:3000 with both frontend and backend running, or configure your server to proxy /api to the backend.';
return 'La app no pudo conectar con la API (404). Usa http://localhost:3000 con frontend y backend activos.';
}
return 'Geocode service not found. Update the backend and restart it.';
return 'Servicio de geocodificación no encontrado. Actualiza el backend y reinícialo.';
}
return `Lookup failed (HTTP ${response.status}).`;
return `Búsqueda fallida (HTTP ${response.status}).`;
}
function PharmacyManagement() {
@@ -132,7 +132,7 @@ function PharmacyManagement() {
setPharmacies(data);
} catch (error) {
console.error('Error fetching pharmacies:', error);
alert('Error loading pharmacies');
alert('Error al cargar farmacias');
} finally {
setLoading(false);
}
@@ -165,7 +165,7 @@ function PharmacyManagement() {
e?.preventDefault();
const q = cityQuery.trim();
if (!q) {
setCityLookupMessage({ type: 'err', text: 'Enter a city or place name.' });
setCityLookupMessage({ type: 'err', text: 'Introduce una ciudad o lugar.' });
return;
}
setCityLookupLoading(true);
@@ -253,7 +253,7 @@ function PharmacyManagement() {
const lon = parseFloat(regionLon);
const radio = parseFloat(regionRadio);
if (!Number.isFinite(lat) || !Number.isFinite(lon) || !Number.isFinite(radio)) {
throw new Error('Set latitude, longitude and radius (use Find city or a preset).');
throw new Error('Establece latitud, longitud y radio (usa Buscar ciudad o un preset).');
}
const response = await fetch('/api/admin/pharmacies/import-external', {
method: 'POST',
@@ -307,7 +307,7 @@ function PharmacyManagement() {
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to update pharmacy');
throw new Error(error.error || 'Error al actualizar farmacia');
}
} else {
const response = await fetch('/api/admin/pharmacies', {
@@ -319,16 +319,16 @@ function PharmacyManagement() {
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to create pharmacy');
throw new Error(error.error || 'Error al crear farmacia');
}
}
resetForm();
fetchPharmacies();
alert(editingPharmacy ? 'Pharmacy updated successfully!' : 'Pharmacy added successfully!');
alert(editingPharmacy ? '¡Farmacia actualizada!' : '¡Farmacia añadida!');
} catch (error) {
console.error('Error saving pharmacy:', error);
alert(`Error saving pharmacy: ${error.message}`);
alert(`Error al guardar farmacia: ${error.message}`);
} finally {
setSaving(false);
}
@@ -348,7 +348,7 @@ function PharmacyManagement() {
};
const handleDelete = async (id) => {
if (!confirm('Are you sure you want to delete this pharmacy?')) return;
if (!confirm('¿Estás seguro de que quieres eliminar esta farmacia?')) return;
try {
const response = await fetch(`/api/admin/pharmacies/${id}`, {
@@ -356,13 +356,13 @@ function PharmacyManagement() {
credentials: 'include',
});
if (!response.ok) throw new Error('Failed to delete pharmacy');
if (!response.ok) throw new Error('Error al eliminar farmacia');
fetchPharmacies();
alert('Pharmacy deleted successfully!');
alert('¡Farmacia eliminada!');
} catch (error) {
console.error('Error deleting pharmacy:', error);
alert('Error deleting pharmacy');
alert('Error al eliminar farmacia');
}
};
@@ -391,7 +391,7 @@ function PharmacyManagement() {
return (
<div className="admin-section">
<div className="section-header">
<h2>Manage Pharmacies</h2>
<h2>Gestionar Farmacias</h2>
<button
type="button"
className="btn-primary"
@@ -400,16 +400,16 @@ function PharmacyManagement() {
setShowForm(true);
}}
>
+ Add New Pharmacy
+ Añadir Nueva Farmacia
</button>
</div>
<div className="pharmacy-tools-card">
<h3>City, region &amp; import</h3>
<h3>Ciudad, región e importación</h3>
<p className="pharmacy-tools-hint">
<strong>Find city</strong> sets latitude, longitude and radius for the map filter and for imports.
Choose a <strong>data source</strong> below: <strong>OpenStreetMap</strong> is free (no key);{' '}
<strong>Open data URL</strong> loads JSON you host (array or GeoJSON). Geocoding uses{' '}
<strong>Buscar ciudad</strong> establece latitud, longitud y radio para el filtro de mapa y las importaciones.
Elige una <strong>fuente de datos</strong>: <strong>OpenStreetMap</strong> es gratuita (sin clave);{' '}
<strong>URL de datos abiertos</strong> carga JSON alojado por ti. Geocodificación usa{' '}
<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noreferrer">
OpenStreetMap
</a>{' '}
@@ -418,11 +418,11 @@ function PharmacyManagement() {
<form className="city-lookup-form" onSubmit={handleCityLookup}>
<div className="form-group city-lookup-input-wrap">
<label htmlFor="city-finder">Find city</label>
<label htmlFor="city-finder">Buscar ciudad</label>
<input
id="city-finder"
type="search"
placeholder="e.g. Rubí, Spain — or Madrid, Valencia…"
placeholder="Ej: Rubí, Madrid, Valencia…"
value={cityQuery}
onChange={(e) => {
setCityQuery(e.target.value);
@@ -436,7 +436,7 @@ function PharmacyManagement() {
className="btn-secondary city-lookup-submit"
disabled={cityLookupLoading}
>
{cityLookupLoading ? 'Looking up…' : 'Look up city'}
{cityLookupLoading ? 'Buscando…' : 'Buscar ciudad'}
</button>
</form>
{cityLookupMessage && (
@@ -449,7 +449,7 @@ function PharmacyManagement() {
)}
<div className="region-presets">
<label htmlFor="region-preset">Area preset</label>
<label htmlFor="region-preset">Preset de área</label>
<select
id="region-preset"
value={regionPreset}
@@ -465,7 +465,7 @@ function PharmacyManagement() {
<div className="region-grid">
<div className="form-group">
<label htmlFor="region-lat">Latitude</label>
<label htmlFor="region-lat">Latitud</label>
<input
id="region-lat"
type="text"
@@ -476,7 +476,7 @@ function PharmacyManagement() {
/>
</div>
<div className="form-group">
<label htmlFor="region-lon">Longitude</label>
<label htmlFor="region-lon">Longitud</label>
<input
id="region-lon"
type="text"
@@ -487,7 +487,7 @@ function PharmacyManagement() {
/>
</div>
<div className="form-group">
<label htmlFor="region-radio">Radius (m)</label>
<label htmlFor="region-radio">Radio (m)</label>
<input
id="region-radio"
type="text"
@@ -501,7 +501,7 @@ function PharmacyManagement() {
<div className="import-mode-row">
<div className="form-group import-mode-select-wrap">
<label htmlFor="import-mode">Data source</label>
<label htmlFor="import-mode">Fuente de datos</label>
<select
id="import-mode"
value={importMode}
@@ -510,16 +510,16 @@ function PharmacyManagement() {
setImportFeedback(null);
}}
>
<option value="osm">OpenStreetMap (Overpass, free)</option>
<option value="webhook">n8n webhook (legacy)</option>
<option value="openData">Open data JSON URL</option>
<option value="osm">OpenStreetMap (Overpass, gratuito)</option>
<option value="webhook">n8n webhook (heredado)</option>
<option value="openData">URL de datos abiertos JSON</option>
</select>
</div>
</div>
{importMode === 'openData' && (
<div className="form-group open-data-url-row">
<label htmlFor="open-data-url">JSON URL</label>
<label htmlFor="open-data-url">URL JSON</label>
<input
id="open-data-url"
type="url"
@@ -539,12 +539,12 @@ function PharmacyManagement() {
disabled={importing}
>
{importing
? 'Importing…'
? 'Importando…'
: importMode === 'webhook'
? 'Import from webhook'
? 'Importar desde webhook'
: importMode === 'openData'
? 'Import from URL'
: `Import from ${importMode === 'osm' ? 'Overpass' : 'OpenStreetMap'}`}
? 'Importar desde URL'
: `Importar desde ${importMode === 'osm' ? 'Overpass' : 'OpenStreetMap'}`}
</button>
<label className="filter-region-toggle">
<input
@@ -552,7 +552,7 @@ function PharmacyManagement() {
checked={filterByRegion}
onChange={(e) => setFilterByRegion(e.target.checked)}
/>
Show only pharmacies inside radius
Mostrar solo farmacias dentro del radio
</label>
</div>
@@ -568,10 +568,10 @@ function PharmacyManagement() {
{showForm && (
<form className="admin-form" onSubmit={handleSubmit}>
<h3>{editingPharmacy ? 'Edit Pharmacy' : 'Add New Pharmacy'}</h3>
<h3>{editingPharmacy ? 'Editar Farmacia' : 'Añadir Nueva Farmacia'}</h3>
<div className="form-group">
<label>Name *</label>
<label>Nombre *</label>
<input
type="text"
value={formData.name}
@@ -581,7 +581,7 @@ function PharmacyManagement() {
</div>
<div className="form-group">
<label>Address *</label>
<label>Dirección *</label>
<input
type="text"
value={formData.address}
@@ -591,7 +591,7 @@ function PharmacyManagement() {
</div>
<div className="form-group">
<label>Phone</label>
<label>Teléfono</label>
<input
type="text"
value={formData.phone}
@@ -601,7 +601,7 @@ function PharmacyManagement() {
<div className="form-row">
<div className="form-group">
<label>Latitude</label>
<label>Latitud</label>
<input
type="number"
step="any"
@@ -611,7 +611,7 @@ function PharmacyManagement() {
</div>
<div className="form-group">
<label>Longitude</label>
<label>Longitud</label>
<input
type="number"
step="any"
@@ -622,8 +622,8 @@ function PharmacyManagement() {
</div>
<fieldset className="hours-editor">
<legend>Opening hours</legend>
<p className="hours-editor-hint">Leave a day checked as <em>Closed</em> if the pharmacy doesn't open that day.</p>
<legend>Horario de apertura</legend>
<p className="hours-editor-hint">Marca un día como <em>Cerrado</em> si la farmacia no abre ese día.</p>
{DAY_KEYS.map((day) => {
const d = hoursDraft[day];
return (
@@ -635,14 +635,14 @@ function PharmacyManagement() {
checked={d.closed}
onChange={(e) => updateDay(day, { closed: e.target.checked })}
/>
Closed
Cerrado
</label>
<input
type="time"
value={d.open}
disabled={d.closed}
onChange={(e) => updateDay(day, { open: e.target.value })}
aria-label={`${DAY_LABEL[day]} opens at`}
aria-label={`${DAY_LABEL[day]} abre a las`}
/>
<span className="hours-sep"></span>
<input
@@ -650,7 +650,7 @@ function PharmacyManagement() {
value={d.close}
disabled={d.closed}
onChange={(e) => updateDay(day, { close: e.target.value })}
aria-label={`${DAY_LABEL[day]} closes at`}
aria-label={`${DAY_LABEL[day]} cierra a las`}
/>
</div>
);
@@ -659,28 +659,28 @@ function PharmacyManagement() {
<div className="form-actions">
<button type="submit" className="btn-primary" disabled={saving}>
{saving ? 'Saving...' : editingPharmacy ? 'Update' : 'Add'} Pharmacy
{saving ? 'Guardando...' : editingPharmacy ? 'Actualizar' : 'Añadir'} Farmacia
</button>
<button type="button" className="btn-secondary" onClick={resetForm} disabled={saving}>
Cancel
Cancelar
</button>
</div>
</form>
)}
{loading ? (
<div className="loading">Loading pharmacies...</div>
<div className="loading">Cargando farmacias...</div>
) : (
<div className="admin-list">
<p className="list-meta">
Showing {displayedPharmacies.length} of {pharmacies.length} pharmacies
{filterByRegion && ' (inside radius)'}
Mostrando {displayedPharmacies.length} de {pharmacies.length} farmacias
{filterByRegion && ' (dentro del radio)'}
</p>
{displayedPharmacies.length === 0 ? (
<p className="empty-state">
{pharmacies.length === 0
? 'No pharmacies yet. Import from webhook or add one manually.'
: 'No pharmacies in this radius with coordinates. Widen the radius, look up a different city, or turn off the region filter.'}
? 'Aún no hay farmacias. Importa desde webhook o añade una manualmente.'
: 'No hay farmacias en este radio con coordenadas. Amplía el radio, busca otra ciudad o desactiva el filtro de región.'}
</p>
) : (
displayedPharmacies.map((pharmacy) => (
@@ -697,10 +697,10 @@ function PharmacyManagement() {
</div>
<div className="item-actions">
<button type="button" className="btn-edit" onClick={() => handleEdit(pharmacy)}>
Edit
Editar
</button>
<button type="button" className="btn-delete" onClick={() => handleDelete(pharmacy.id)}>
Delete
Eliminar
</button>
</div>
</div>
@@ -111,7 +111,7 @@ function PharmacyMedicineLink() {
e.preventDefault();
if (!selectedMedicine) {
alert('Please select a medicine first');
alert('Por favor, selecciona un medicamento primero');
return;
}
@@ -131,16 +131,16 @@ function PharmacyMedicineLink() {
body: JSON.stringify(payload)
});
if (!response.ok) throw new Error('Failed to link medicine to pharmacy');
if (!response.ok) throw new Error('Error al vincular medicamento a farmacia');
resetForm();
if (selectedPharmacy) {
fetchPharmacyMedicines(selectedPharmacy.id);
}
alert('Medicine linked to pharmacy successfully!');
alert('¡Medicamento vinculado a la farmacia correctamente!');
} catch (error) {
console.error('Error linking medicine:', error);
alert('Error linking medicine to pharmacy');
alert('Error al vincular medicamento a farmacia');
}
};
@@ -153,18 +153,18 @@ function PharmacyMedicineLink() {
body: JSON.stringify({ price, stock })
});
if (!response.ok) throw new Error('Failed to update');
if (!response.ok) throw new Error('Error al actualizar');
fetchPharmacyMedicines(selectedPharmacy.id);
alert('Updated successfully!');
alert('¡Actualizado correctamente!');
} catch (error) {
console.error('Error updating:', error);
alert('Error updating');
alert('Error al actualizar');
}
};
const handleDelete = async (id) => {
if (!confirm('Remove this medicine from the pharmacy?')) return;
if (!confirm('¿Eliminar este medicamento de la farmacia?')) return;
try {
const response = await fetch(`/api/admin/pharmacy-medicines/${id}`, {
@@ -172,13 +172,13 @@ function PharmacyMedicineLink() {
credentials: 'include'
});
if (!response.ok) throw new Error('Failed to delete');
if (!response.ok) throw new Error('Error al eliminar');
fetchPharmacyMedicines(selectedPharmacy.id);
alert('Medicine removed from pharmacy!');
alert('¡Medicamento eliminado de la farmacia!');
} catch (error) {
console.error('Error deleting:', error);
alert('Error removing medicine');
alert('Error al eliminar medicamento');
}
};
@@ -216,11 +216,11 @@ function PharmacyMedicineLink() {
return (
<div className="admin-section">
<h2>Link Medicine to Pharmacy</h2>
<h2>Vincular Medicamento a Farmacia</h2>
<form className="admin-form" onSubmit={handleSubmit}>
<div className="form-group">
<label>Pharmacy *</label>
<label>Farmacia *</label>
<input
ref={pharmacyInputRef}
type="text"
@@ -235,7 +235,7 @@ function PharmacyMedicineLink() {
}}
onFocus={() => setPharmacyDropdownOpen(true)}
onBlur={() => setTimeout(() => setPharmacyDropdownOpen(false), 150)}
placeholder={pharmacies.length ? `Search ${pharmacies.length} pharmacies by name or address` : 'Loading pharmacies…'}
placeholder={pharmacies.length ? `Buscar entre ${pharmacies.length} farmacias por nombre o dirección` : 'Cargando farmacias…'}
autoComplete="off"
required={!selectedPharmacy}
/>
@@ -243,7 +243,7 @@ function PharmacyMedicineLink() {
<div className="medicine-search-results">
{filteredPharmacies.length === 0 ? (
<div className="search-result-item search-result-item--empty">
<span>No pharmacies match "{pharmacyQuery}"</span>
<span>No hay farmacias que coincidan con "{pharmacyQuery}"</span>
</div>
) : (
filteredPharmacies.map((pharmacy) => (
@@ -264,14 +264,14 @@ function PharmacyMedicineLink() {
<p> Selected: <strong>{selectedPharmacy.name}</strong></p>
<p className="medicine-details">{selectedPharmacy.address}</p>
<button type="button" className="btn-small" onClick={clearPharmacy}>
Change pharmacy
Cambiar farmacia
</button>
</div>
)}
</div>
<div className="form-group">
<label>Search Medicine (CIMA API) *</label>
<label>Buscar Medicamento (API CIMA) *</label>
<input
type="text"
value={medicineSearch}
@@ -279,10 +279,10 @@ function PharmacyMedicineLink() {
setMedicineSearch(e.target.value);
setSelectedMedicine(null);
}}
placeholder="Type to search medicines from CIMA..."
placeholder="Escribe para buscar medicamentos en CIMA..."
required
/>
{searching && <p className="loading-text">Searching...</p>}
{searching && <p className="loading-text">Buscando...</p>}
{medicineResults.length > 0 && !selectedMedicine && (
<div className="medicine-search-results">
@@ -316,7 +316,7 @@ function PharmacyMedicineLink() {
setMedicineSearch('');
}}
>
Change medicine
Cambiar medicamento
</button>
</div>
)}
@@ -324,7 +324,7 @@ function PharmacyMedicineLink() {
<div className="form-row">
<div className="form-group">
<label>Price ()</label>
<label>Precio ()</label>
<input
type="number"
step="0.01"
@@ -347,21 +347,21 @@ function PharmacyMedicineLink() {
<div className="form-actions">
<button type="submit" className="btn-primary">
Link Medicine
Vincular Medicamento
</button>
<button type="button" className="btn-secondary" onClick={resetForm}>
Reset
Reiniciar
</button>
</div>
</form>
{selectedPharmacy && (
<div className="pharmacy-medicines-section">
<h3>Medicines at {selectedPharmacy.name}</h3>
<h3>Medicamentos en {selectedPharmacy.name}</h3>
{loading ? (
<div className="loading">Loading...</div>
<div className="loading">Cargando...</div>
) : pharmacyMedicines.length === 0 ? (
<p className="empty-state">No medicines linked to this pharmacy yet.</p>
<p className="empty-state">Aún no hay medicamentos vinculados a esta farmacia.</p>
) : (
<div className="admin-list">
{pharmacyMedicines.map((pm) => (
@@ -369,7 +369,7 @@ function PharmacyMedicineLink() {
<div className="item-content">
<h4>{pm.medicine_name}</h4>
<p>
<strong>Price:</strong> {pm.price ? `${parseFloat(pm.price).toFixed(2)}` : 'Not set'}
<strong>Precio:</strong> {pm.price ? `${parseFloat(pm.price).toFixed(2)}` : 'No definido'}
<strong> Stock:</strong> {pm.stock || 0}
</p>
</div>
@@ -377,17 +377,17 @@ function PharmacyMedicineLink() {
<button
className="btn-edit"
onClick={() => {
const newPrice = prompt('Enter new price:', pm.price || '');
const newStock = prompt('Enter new stock:', pm.stock || '0');
const newPrice = prompt('Introduce nuevo precio:', pm.price || '');
const newStock = prompt('Introduce nuevo stock:', pm.stock || '0');
if (newPrice !== null && newStock !== null) {
handleUpdate(pm.id, newPrice ? parseFloat(newPrice) : null, parseInt(newStock) || 0);
}
}}
>
Update
Actualizar
</button>
<button className="btn-delete" onClick={() => handleDelete(pm.id)}>
Remove
Eliminar
</button>
</div>
</div>
+2 -2
View File
@@ -14,10 +14,10 @@ self.addEventListener('push', (event) => {
try {
data = event.data.json();
} catch {
data = { title: 'FarmaFinder', body: event.data.text() };
data = { title: 'FarmaClic', body: event.data.text() };
}
}
const title = data.title || 'FarmaFinder';
const title = data.title || 'FarmaClic';
const options = {
body: data.body || '',
icon: '/icon.svg',
+2 -2
View File
@@ -4,7 +4,7 @@
//
// Required env vars (Vite injects VITE_* at build time):
// VITE_FARO_ENDPOINT — e.g. http://localhost:4318
// VITE_FARO_APP_NAME — e.g. farmafinder-frontend
// VITE_FARO_APP_NAME — e.g. farmaclic-frontend
// VITE_FARO_ENV — e.g. production | staging | development
// VITE_FARO_APP_VERSION — optional, defaults to 1.0.0
@@ -29,7 +29,7 @@ export function initFaro() {
return;
}
const appName = import.meta.env.VITE_FARO_APP_NAME || 'farmafinder-frontend';
const appName = import.meta.env.VITE_FARO_APP_NAME || 'farmaclic-frontend';
const environment = import.meta.env.VITE_FARO_ENV || 'production';
const version = import.meta.env.VITE_FARO_APP_VERSION || '1.0.0';
+2 -2
View File
@@ -13,11 +13,11 @@ export function haversineKm(lat1, lon1, lat2, lon2) {
export function getUserPosition() {
return new Promise((resolve, reject) => {
if (!navigator.geolocation) {
reject(new Error('Geolocation not supported by this browser'));
reject(new Error('Geolocalización no compatible con este navegador'));
return;
}
if (typeof window !== 'undefined' && window.isSecureContext === false) {
reject(new Error('Geolocation requires HTTPS (or localhost)'));
reject(new Error('La geolocalización requiere HTTPS (o localhost)'));
return;
}
navigator.geolocation.getCurrentPosition(
+13 -13
View File
@@ -1,12 +1,12 @@
const DAYS = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'];
const DAY_LABELS = {
sun: 'Sunday',
mon: 'Monday',
tue: 'Tuesday',
wed: 'Wednesday',
thu: 'Thursday',
fri: 'Friday',
sat: 'Saturday',
sun: 'Domingo',
mon: 'Lunes',
tue: 'Martes',
wed: 'Miércoles',
thu: 'Jueves',
fri: 'Viernes',
sat: 'Sábado',
};
export const DAY_KEYS = DAYS;
@@ -33,8 +33,8 @@ function findNextOpen(hours, now) {
const range = hours[day];
if (Array.isArray(range) && range.length === 2) {
const openStr = range[0];
if (offset === 1) return `tomorrow at ${openStr}`;
return `${DAY_LABELS[day]} at ${openStr}`;
if (offset === 1) return `mañana a las ${openStr}`;
return `${DAY_LABELS[day]} a las ${openStr}`;
}
}
return null;
@@ -49,7 +49,7 @@ export function getOpenStatus(rawHours, now = new Date()) {
if (!Array.isArray(range) || range.length !== 2) {
const next = findNextOpen(hours, now);
return { status: 'closed', label: next ? `Closed · Opens ${next}` : 'Closed' };
return { status: 'closed', label: next ? `Cerrado · Abre ${next}` : 'Cerrado' };
}
const openMins = parseHM(range[0]);
@@ -59,13 +59,13 @@ export function getOpenStatus(rawHours, now = new Date()) {
const nowMins = now.getHours() * 60 + now.getMinutes();
if (nowMins < openMins) {
return { status: 'closed', label: `Closed · Opens at ${range[0]}` };
return { status: 'closed', label: `Cerrado · Abre a las ${range[0]}` };
}
if (nowMins >= closeMins) {
const next = findNextOpen(hours, now);
return { status: 'closed', label: next ? `Closed · Opens ${next}` : 'Closed' };
return { status: 'closed', label: next ? `Cerrado · Abre ${next}` : 'Cerrado' };
}
return { status: 'open', label: `Open · Closes at ${range[1]}` };
return { status: 'open', label: `Abierto · Cierra a las ${range[1]}` };
}
export function emptyHours() {
+8 -8
View File
@@ -1,4 +1,4 @@
const STORAGE_KEY = 'farmafinder.subscriptions.v1';
const STORAGE_KEY = 'farmaclic.subscriptions.v1';
export function getStoredSubscriptions() {
try {
@@ -38,12 +38,12 @@ export async function getVapidPublicKey() {
const res = await fetch('/api/notifications/vapid-public-key');
if (!res.ok) {
const message = res.status === 503
? 'Push notifications are not configured on the server yet'
: `Could not fetch VAPID key (HTTP ${res.status})`;
? 'Las notificaciones push no están configuradas en el servidor'
: `No se pudo obtener la clave VAPID (HTTP ${res.status})`;
throw new Error(message);
}
const { publicKey } = await res.json();
if (!publicKey) throw new Error('Server returned an empty VAPID key');
if (!publicKey) throw new Error('El servidor devolvió una clave VAPID vacía');
return publicKey;
}
@@ -58,17 +58,17 @@ export function urlBase64ToUint8Array(base64String) {
async function getOrCreateSubscription() {
if (!pushSupported()) {
throw new Error('Push notifications are not supported in this browser');
throw new Error('Las notificaciones push no son compatibles con este navegador');
}
if (window.isSecureContext === false) {
throw new Error('Push notifications require HTTPS (or localhost)');
throw new Error('Las notificaciones push requieren HTTPS (o localhost)');
}
const reg = await navigator.serviceWorker.ready;
let sub = await reg.pushManager.getSubscription();
if (sub) return sub;
if (Notification.permission === 'denied') {
throw new Error('Notification permission was denied — enable it in browser settings');
throw new Error('Permiso de notificaciones denegado — actívalo en la configuración del navegador');
}
if (Notification.permission === 'default') {
const result = await Notification.requestPermission();
@@ -98,7 +98,7 @@ export async function subscribeToPush(medicineNregistro, medicineName, pharmacyI
});
if (!res.ok) {
const detail = await res.text().catch(() => '');
throw new Error(`Subscribe failed (HTTP ${res.status}) ${detail}`);
throw new Error(`Suscripción fallida (HTTP ${res.status}) ${detail}`);
}
setStoredSubscriptions([...getStoredSubscriptions(), storageKey(medicineNregistro, pharmacyId)]);
}
+9 -9
View File
@@ -59,7 +59,7 @@ function AdminView() {
if (loading) {
return (
<div className="app-main">
<div className="loading">Checking authentication...</div>
<div className="loading">Comprobando autenticación...</div>
</div>
);
}
@@ -68,8 +68,8 @@ function AdminView() {
return (
<>
<header className="app-header">
<h1> Admin Panel</h1>
<p>Authentication required</p>
<h1> Panel de Administración</h1>
<p>Autenticación requerida</p>
</header>
<main className="app-main">
<LoginForm onLogin={handleLogin} />
@@ -83,13 +83,13 @@ function AdminView() {
<header className="app-header">
<div className="admin-header-content">
<div>
<h1> Admin Panel</h1>
<p>Manage pharmacies and medicines</p>
<h1> Panel de Administración</h1>
<p>Gestiona farmacias y medicamentos</p>
</div>
<div className="admin-user-info">
<span>👤 {user?.username}</span>
<button className="logout-button" onClick={handleLogout}>
Logout
Cerrar sesión
</button>
</div>
</div>
@@ -101,19 +101,19 @@ function AdminView() {
className={`admin-tab ${activeTab === 'pharmacies' ? 'active' : ''}`}
onClick={() => setActiveTab('pharmacies')}
>
🏥 Pharmacies
🏥 Farmacias
</button>
<button
className={`admin-tab ${activeTab === 'medicines' ? 'active' : ''}`}
onClick={() => setActiveTab('medicines')}
>
💊 Medicines
💊 Medicamentos
</button>
<button
className={`admin-tab ${activeTab === 'link' ? 'active' : ''}`}
onClick={() => setActiveTab('link')}
>
🔗 Link Medicine to Pharmacy
🔗 Vincular Medicamento a Farmacia
</button>
</div>
+11 -1
View File
@@ -20,16 +20,26 @@
.home-logo-wrapper {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 1rem;
margin-bottom: 0.5rem;
}
.home-logo {
width: min(16rem, 70vw);
width: min(10rem, 50vw);
height: auto;
}
.home-brand-name {
font-size: 2rem;
font-weight: 800;
color: var(--primary);
letter-spacing: -0.02em;
line-height: 1;
}
@media (max-height: 700px) {
.home-logo {
width: min(12rem, 60vw);
+2 -1
View File
@@ -6,7 +6,8 @@ function HomeView({ onScanClick, onSearchClick }) {
<div className="home-view">
<div className="home-hero">
<div className="home-logo-wrapper">
<img src="/logo.png" alt="FarmaFinder" className="home-logo" />
<img src="/logo.png" alt="FarmaClic" className="home-logo" />
<h1 className="home-brand-name">FarmaClic</h1>
</div>
<p className="home-desc">Encuentra tus medicamentos en farmacias cercanas</p>
</div>
+20 -20
View File
@@ -108,12 +108,12 @@ function PublicView({
console.error('[location] error', err);
let msg;
if (err && typeof err.code === 'number') {
if (err.code === 1) msg = 'Location permission denied — allow it in your browser settings';
else if (err.code === 2) msg = 'Location unavailable — check OS location services / WiFi';
else if (err.code === 3) msg = 'Location request timed out — try again';
else msg = `Geolocation error (code ${err.code})`;
if (err.code === 1) msg = 'Permiso de ubicación denegado — actívalo en la configuración del navegador';
else if (err.code === 2) msg = 'Ubicación no disponible — comprueba los servicios de ubicación/WiFi';
else if (err.code === 3) msg = 'La solicitud de ubicación expiró — inténtalo de nuevo';
else msg = `Error de geolocalización (código ${err.code})`;
} else {
msg = err && err.message ? err.message : 'Could not get your location';
msg = err && err.message ? err.message : 'No se pudo obtener tu ubicación';
}
setLocationError(msg);
} finally {
@@ -172,22 +172,22 @@ function PublicView({
setPharmacies([]);
setScreen('home');
}}
aria-label="Back to home"
aria-label="Volver al inicio"
>
Home
Inicio
</button>
<h1>💊 FarmaFinder</h1>
<p>Find your medicine at nearby pharmacies</p>
<h1>💊 FarmaClic</h1>
<p>Encuentra tu medicamento en farmacias cercanas</p>
</header>
<main className="app-main">
<SearchBar
value={searchQuery}
onChange={setSearchQuery}
placeholder="Search for a medicine..."
placeholder="Buscar un medicamento..."
/>
{loading && <div className="loading">Searching...</div>}
{loading && <div className="loading">Buscando...</div>}
{searchQuery && !selectedMedicine && (
<MedicineResults
@@ -204,9 +204,9 @@ function PublicView({
<div className="medicine-info">
<h2>{selectedMedicine.name}</h2>
<div className="medicine-details">
<span><strong>Active Ingredient:</strong> {selectedMedicine.active_ingredient}</span>
<span><strong>Dosage:</strong> {selectedMedicine.dosage}</span>
<span><strong>Form:</strong> {selectedMedicine.form}</span>
<span><strong>Principio Activo:</strong> {selectedMedicine.active_ingredient}</span>
<span><strong>Dosis:</strong> {selectedMedicine.dosage}</span>
<span><strong>Forma:</strong> {selectedMedicine.form}</span>
</div>
<button
className="back-button"
@@ -215,7 +215,7 @@ function PublicView({
setPharmacies([]);
}}
>
Back to search
Volver a búsqueda
</button>
</div>
@@ -227,15 +227,15 @@ function PublicView({
disabled={locating}
>
{locating
? '📍 Locating…'
? '📍 Localizando…'
: sortByDistance
? '📍 Sorted by distance · Reset'
? '📍 Ordenado por distancia · Reset'
: hasSavedCoords
? '📍 Sort by your saved location'
: '📍 Sort by distance'}
? '📍 Ordenar por ubicación guardada'
: '📍 Ordenar por distancia'}
</button>
{sortByDistance && positionSource === 'profile' && (
<span className="location-source">Using your saved address</span>
<span className="location-source">Usando tu dirección guardada</span>
)}
{locationError && (
<span className="location-error">{locationError}</span>
+3 -3
View File
@@ -17,9 +17,9 @@ export default defineConfig({
navigateFallback: 'index.html',
},
manifest: {
name: 'FarmaFinder',
short_name: 'FarmaFinder',
description: 'Find pharmacies that stock the medicine you need',
name: 'FarmaClic',
short_name: 'FarmaClic',
description: 'Encuentra medicamentos en farmacias cercanas',
theme_color: '#00450d',
background_color: '#f8fafb',
display: 'standalone',
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "farma-finder",
"name": "farma-clic",
"version": "1.0.0",
"description": "FarmaFinder - Full stack application",
"description": "FarmaClic - Full stack application",
"private": true,
"scripts": {
"dev": "npm-run-all --parallel dev:backend dev:frontend",
+37
View File
@@ -0,0 +1,37 @@
APP_NAME=PIP - Pharmacy Integration Platform
APP_VERSION=0.1.0
DEBUG=false
DATABASE_URL=postgresql+asyncpg://pip:pip-secret@localhost:5432/pip
DATABASE_POOL_SIZE=20
DATABASE_MAX_OVERFLOW=10
DATABASE_POOL_RECYCLE=3600
REDIS_URL=redis://localhost:6379/0
REDIS_CACHE_TTL=300
RABBITMQ_URL=amqp://pip:pip-secret@localhost:5672/pip
JWT_SECRET_KEY=change-me-in-production
JWT_ALGORITHM=HS256
JWT_ACCESS_TOKEN_EXPIRE_MINUTES=30
JWT_REFRESH_TOKEN_EXPIRE_DAYS=7
JWT_ISSUER=pip-platform
RATE_LIMIT_PER_MINUTE=60
RATE_LIMIT_AUTH_PER_MINUTE=10
OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4317
OTEL_SERVICE_NAME=pip-platform
OTEL_TRACES_ENABLED=true
OTEL_METRICS_ENABLED=true
LOG_LEVEL=INFO
LOG_JSON_FORMAT=true
CORS_ORIGINS=["*"]
CORS_ALLOW_CREDENTIALS=true
PG_PASSWORD=pip-secret
RABBITMQ_PASSWORD=pip-secret
GRAFANA_PASSWORD=admin
+20
View File
@@ -0,0 +1,20 @@
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
build/
dist/
*.egg-info/
.eggs/
*.egg
.env
.venv/
venv/
.pytest_cache/
.mypy_cache/
.ruff_cache/
.coverage
htmlcov/
*.sqlite3
.alembic/versions/*.pyc
+18
View File
@@ -0,0 +1,18 @@
FROM python:3.13-slim AS base
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
libpq-dev \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY pyproject.toml ./
RUN pip install --no-cache-dir --upgrade pip && \
pip install --no-cache-dir .
COPY . .
EXPOSE 8000
CMD ["python", "entrypoint.py"]
+65
View File
@@ -0,0 +1,65 @@
# PIP Platform — State Tracker
## Current Phase: 3 (Complete)
### Phase 1 — Foundation ✅
- FastAPI app skeleton, Settings, Docker Compose (Postgres, Redis, RabbitMQ)
- SQLAlchemy async models + Alembic migration `001_initial`
- Auth: JWT (access/refresh), RBAC (admin/operator/viewer), user CRUD
- Entities: Pharmacy, Connector, Medication, Inventory, Reservation, ERP System
- Repositories + Services for all entities
- API v1 endpoints for all entities
- Middleware: CORS, request logging, metrics
- Observability: OpenTelemetry traces, Prometheus metrics
### Phase 2 — Connector SDK ✅
- Abstract connector interface + Mock connector implementation
- Connector registry, version management, config CRUD
- Medicationcatalog + inventory sync plumbing
### Phase 3 — Sync, Audit, API Keys, Rate Limiting ✅
- **Sync Jobs**: Repository, service, API endpoints (create, list, get, trigger, complete, fail, cancel, logs, status filter)
- **Audit Logging**: AuditService (`log_action`, `list_logs`), AuditMiddleware (auto-logs POST/PUT/PATCH/DELETE), audit endpoint
- **API Key Auth**: Dual auth via `get_authenticated_user` — supports both Bearer JWT and X-API-Key header; `require_role` and `require_scope` use it; API Key CRUD endpoints
- **Rate Limiting**: Custom `RateLimitMiddleware` (sliding window, X-RateLimit-* headers, 429 with Retry-After); exempt paths (docs, metrics, health)
- **RabbitMQ**: Routing keys for `sync_created`, `sync_triggered`, `sync_completed`, `sync_failed`, `sync_cancelled`
## Architecture Decisions
- Clean Architecture: domain entities → services → API endpoints → infrastructure
- Repository pattern with SQLAlchemy async
- Dual auth: JWT Bearer + X-API-Key in single `get_current_user` dependency
- Audit middleware uses separate DB session (via `async_session_factory`) to decouple from request session
- Rate limiting: custom middleware replacing slowapi (incompatible with FastAPI 0.138+ `_IncludedRouter`)
- DB migrations: Alembic `upgrade head` runs in app lifespan (not `create_all`)
- `APIKeyEntity.key_hash` is `bytes` (sha256 digest), ORM model uses `LargeBinary(64)`
## Key Files
| File | Purpose |
|------|---------|
| `src/main.py` | App factory, lifespan (alembic upgrade), middleware chain |
| `src/api/v1/dependencies/auth.py` | Dual auth: JWT + API Key, require_role, require_scope |
| `src/api/middleware/audit.py` | Auto-audit for write endpoints |
| `src/api/middleware/rate_limit.py` | Custom sliding window rate limiter |
| `src/api/v1/endpoints/sync_jobs.py` | Sync job CRUD + lifecycle |
| `src/api/v1/endpoints/api_clients.py` | API client + key management |
| `src/api/v1/endpoints/audit.py` | Audit log query endpoint |
| `src/services/synchronization/sync_service.py` | Sync job business logic |
| `src/services/audit/audit_service.py` | Audit service |
| `src/infrastructure/messaging/rabbitmq.py` | Event publishing routing keys |
| `alembic/versions/001_initial.py` | Full schema (all tables including sync_jobs, sync_logs, audit_logs) |
## Configuration (env)
| Variable | Default | Description |
|----------|---------|-------------|
| `RATE_LIMIT_PER_MINUTE` | 60 | Max requests per client per minute |
| `RATE_LIMIT_AUTH_PER_MINUTE` | 10 | (reserved) Auth endpoint limit |
| `JWT_SECRET_KEY` | change-me-in-production | Must be set in production |
| `API_KEY_PREFIX` | pip | (reserved) Key namespace prefix |
## Next Phase Ideas
- Redis-backed rate limiter (for multi-instance)
- Per-client rate limits (use `api_clients.rate_limit` column)
- API Key scope enforcement on specific endpoints (currently role-only)
- Sync job execution worker (consume RabbitMQ events, run connector)
- IP allowlist enforcement for API clients
- Alembic migration 002 for any schema changes
+37
View File
@@ -0,0 +1,37 @@
[alembic]
script_location = alembic
prepend_sys_path = .
sqlalchemy.url = postgresql+psycopg2://pip:pip-secret@localhost:5432/pip
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
+41
View File
@@ -0,0 +1,41 @@
from logging.config import fileConfig
from alembic import context
from sqlalchemy import engine_from_config, pool
from src.infrastructure.config.settings import settings
from src.infrastructure.database.models import Base
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
config.set_main_option("sqlalchemy.url", settings.DATABASE_URL_SYNC)
target_metadata = Base.metadata
def run_migrations_offline() -> None:
url = config.get_main_option("sqlalchemy.url")
context.configure(url=url, target_metadata=target_metadata, literal_binds=True, dialect_opts={"paramstyle": "named"})
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
+24
View File
@@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}
@@ -0,0 +1,306 @@
"""Phase 1 initial schema
Revision ID: 001_initial
Revises: None
Create Date: 2026-06-30
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects.postgresql import JSONB, UUID
revision: str = "001_initial"
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
"erp_systems",
sa.Column("id", UUID(as_uuid=True), primary_key=True),
sa.Column("name", sa.String(255), nullable=False),
sa.Column("erp_type", sa.String(50), nullable=False, index=True),
sa.Column("version", sa.String(50)),
sa.Column("base_url", sa.String(500)),
sa.Column("auth_config", JSONB),
sa.Column("status", sa.String(20), default="pending", index=True),
sa.Column("metadata", JSONB),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), nullable=False),
)
op.create_table(
"pharmacies",
sa.Column("id", UUID(as_uuid=True), primary_key=True),
sa.Column("code", sa.String(50), unique=True, nullable=False, index=True),
sa.Column("name", sa.String(255), nullable=False),
sa.Column("address", sa.String(500)),
sa.Column("city", sa.String(100)),
sa.Column("province", sa.String(100)),
sa.Column("postal_code", sa.String(20)),
sa.Column("country", sa.String(2), default="ES"),
sa.Column("latitude", sa.Float),
sa.Column("longitude", sa.Float),
sa.Column("phone", sa.String(50)),
sa.Column("email", sa.String(255)),
sa.Column("opening_hours", JSONB),
sa.Column("status", sa.String(20), default="pending", index=True),
sa.Column("erp_type", sa.String(50)),
sa.Column("erp_version", sa.String(50)),
sa.Column("sync_strategy", sa.String(20), default="incremental"),
sa.Column("sync_interval_minutes", sa.Integer, default=30),
sa.Column("last_sync_at", sa.DateTime(timezone=True)),
sa.Column("last_sync_status", sa.String(20)),
sa.Column("metadata", JSONB),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), nullable=False),
)
op.create_index("ix_pharmacies_location", "pharmacies", ["latitude", "longitude"])
op.create_index("ix_pharmacies_status_erp", "pharmacies", ["status", "erp_type"])
op.create_table(
"connectors",
sa.Column("id", UUID(as_uuid=True), primary_key=True),
sa.Column("erp_system_id", UUID(as_uuid=True), sa.ForeignKey("erp_systems.id"), nullable=False, index=True),
sa.Column("pharmacy_id", UUID(as_uuid=True), sa.ForeignKey("pharmacies.id"), nullable=False, index=True),
sa.Column("name", sa.String(255), nullable=False),
sa.Column("connector_type", sa.String(50), nullable=False, index=True),
sa.Column("version", sa.String(20), default="1.0.0"),
sa.Column("config", JSONB),
sa.Column("status", sa.String(20), default="pending", index=True),
sa.Column("last_heartbeat_at", sa.DateTime(timezone=True)),
sa.Column("last_error", sa.Text),
sa.Column("metadata", JSONB),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), nullable=False),
sa.UniqueConstraint("erp_system_id", "pharmacy_id", name="uq_connector_erp_pharmacy"),
)
op.add_column("pharmacies", sa.Column("connector_id", UUID(as_uuid=True), sa.ForeignKey("connectors.id"), nullable=True))
op.create_index("ix_pharmacies_connector_id", "pharmacies", ["connector_id"])
op.create_table(
"connector_versions",
sa.Column("id", UUID(as_uuid=True), primary_key=True),
sa.Column("connector_id", UUID(as_uuid=True), sa.ForeignKey("connectors.id"), nullable=False, index=True),
sa.Column("connector_type", sa.String(50), nullable=False, index=True),
sa.Column("version", sa.String(20), nullable=False),
sa.Column("changelog", sa.Text),
sa.Column("download_url", sa.String(500)),
sa.Column("checksum", sa.String(128)),
sa.Column("is_latest", sa.Boolean, default=False, index=True),
sa.Column("is_required", sa.Boolean, default=False),
sa.Column("released_at", sa.DateTime(timezone=True)),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), nullable=False),
sa.UniqueConstraint("connector_type", "version", name="uq_connector_version"),
)
op.create_table(
"connector_configs",
sa.Column("id", UUID(as_uuid=True), primary_key=True),
sa.Column("connector_id", UUID(as_uuid=True), sa.ForeignKey("connectors.id"), nullable=False, index=True),
sa.Column("key", sa.String(100), nullable=False),
sa.Column("value", sa.Text, nullable=False),
sa.Column("encrypted", sa.Boolean, default=False),
sa.Column("description", sa.String(500)),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), nullable=False),
sa.UniqueConstraint("connector_id", "key", name="uq_connector_config_key"),
)
op.create_table(
"medications",
sa.Column("id", UUID(as_uuid=True), primary_key=True),
sa.Column("nregistro", sa.String(50), unique=True, nullable=False, index=True),
sa.Column("name", sa.String(500), nullable=False, index=True),
sa.Column("active_ingredient", sa.String(500)),
sa.Column("dosage", sa.String(100)),
sa.Column("form", sa.String(100)),
sa.Column("atc_code", sa.String(20), index=True),
sa.Column("prescription_required", sa.Boolean, default=False),
sa.Column("manufacturer", sa.String(255)),
sa.Column("metadata", JSONB),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), nullable=False),
)
op.create_table(
"medication_catalog",
sa.Column("id", UUID(as_uuid=True), primary_key=True),
sa.Column("pharmacy_id", UUID(as_uuid=True), sa.ForeignKey("pharmacies.id"), nullable=False, index=True),
sa.Column("medication_id", UUID(as_uuid=True), sa.ForeignKey("medications.id"), nullable=False, index=True),
sa.Column("price", sa.Float),
sa.Column("stock", sa.Integer, default=0),
sa.Column("is_available", sa.Boolean, default=False, index=True),
sa.Column("last_verified_at", sa.DateTime(timezone=True)),
sa.Column("metadata", JSONB),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), nullable=False),
sa.UniqueConstraint("pharmacy_id", "medication_id", name="uq_catalog_pharmacy_medication"),
)
op.create_table(
"inventory_records",
sa.Column("id", UUID(as_uuid=True), primary_key=True),
sa.Column("pharmacy_id", UUID(as_uuid=True), sa.ForeignKey("pharmacies.id"), nullable=False, index=True),
sa.Column("medication_id", UUID(as_uuid=True), sa.ForeignKey("medications.id"), nullable=False, index=True),
sa.Column("stock", sa.Integer, default=0),
sa.Column("reserved_stock", sa.Integer, default=0),
sa.Column("available_stock", sa.Integer, default=0),
sa.Column("min_stock", sa.Integer),
sa.Column("max_stock", sa.Integer),
sa.Column("last_updated_at", sa.DateTime(timezone=True), index=True),
sa.Column("source", sa.String(100)),
sa.Column("batch_id", sa.String(100)),
sa.Column("metadata", JSONB),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), nullable=False),
sa.UniqueConstraint("pharmacy_id", "medication_id", name="uq_inventory_pharmacy_medication"),
)
op.create_table(
"inventory_history",
sa.Column("id", UUID(as_uuid=True), primary_key=True),
sa.Column("inventory_record_id", UUID(as_uuid=True), sa.ForeignKey("inventory_records.id"), nullable=False, index=True),
sa.Column("pharmacy_id", UUID(as_uuid=True), sa.ForeignKey("pharmacies.id"), nullable=False, index=True),
sa.Column("medication_id", UUID(as_uuid=True), sa.ForeignKey("medications.id"), nullable=False, index=True),
sa.Column("previous_stock", sa.Integer, nullable=False),
sa.Column("new_stock", sa.Integer, nullable=False),
sa.Column("delta", sa.Integer, nullable=False),
sa.Column("reason", sa.String(255)),
sa.Column("source", sa.String(100)),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False, index=True),
)
op.create_table(
"technical_users",
sa.Column("id", UUID(as_uuid=True), primary_key=True),
sa.Column("username", sa.String(100), unique=True, nullable=False, index=True),
sa.Column("email", sa.String(255)),
sa.Column("full_name", sa.String(255)),
sa.Column("password_hash", sa.String(255), nullable=False),
sa.Column("role", sa.String(20), default="viewer", index=True),
sa.Column("is_active", sa.Boolean, default=True, index=True),
sa.Column("scopes", JSONB, default=list),
sa.Column("last_login_at", sa.DateTime(timezone=True)),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), nullable=False),
)
op.create_table(
"api_clients",
sa.Column("id", UUID(as_uuid=True), primary_key=True),
sa.Column("name", sa.String(255), nullable=False),
sa.Column("description", sa.Text),
sa.Column("owner_id", UUID(as_uuid=True), sa.ForeignKey("technical_users.id"), nullable=True, index=True),
sa.Column("is_active", sa.Boolean, default=True, index=True),
sa.Column("scopes", JSONB, default=list),
sa.Column("rate_limit", sa.Integer),
sa.Column("allowed_ips", JSONB, default=list),
sa.Column("metadata", JSONB),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), nullable=False),
)
op.create_table(
"api_keys",
sa.Column("id", UUID(as_uuid=True), primary_key=True),
sa.Column("api_client_id", UUID(as_uuid=True), sa.ForeignKey("api_clients.id"), nullable=False, index=True),
sa.Column("key_prefix", sa.String(10), nullable=False, index=True),
sa.Column("key_hash", sa.LargeBinary(64), nullable=False),
sa.Column("name", sa.String(255), nullable=False),
sa.Column("is_active", sa.Boolean, default=True, index=True),
sa.Column("expires_at", sa.DateTime(timezone=True)),
sa.Column("last_used_at", sa.DateTime(timezone=True)),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
op.create_index("ix_api_keys_prefix_active", "api_keys", ["key_prefix", "is_active"])
op.create_table(
"reservations",
sa.Column("id", UUID(as_uuid=True), primary_key=True),
sa.Column("pharmacy_id", UUID(as_uuid=True), sa.ForeignKey("pharmacies.id"), nullable=False, index=True),
sa.Column("medication_id", UUID(as_uuid=True), sa.ForeignKey("medications.id"), nullable=False, index=True),
sa.Column("user_id", UUID(as_uuid=True), sa.ForeignKey("technical_users.id"), nullable=True, index=True),
sa.Column("external_reference", sa.String(255)),
sa.Column("quantity", sa.Integer, nullable=False),
sa.Column("status", sa.String(20), default="pending", index=True),
sa.Column("expires_at", sa.DateTime(timezone=True), index=True),
sa.Column("confirmed_at", sa.DateTime(timezone=True)),
sa.Column("cancelled_at", sa.DateTime(timezone=True)),
sa.Column("idempotency_key", sa.String(255), unique=True, index=True),
sa.Column("metadata", JSONB),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), nullable=False),
)
op.create_index("ix_reservations_pharmacy_status", "reservations", ["pharmacy_id", "status"])
op.create_index("ix_reservations_expires", "reservations", ["expires_at"])
op.create_table(
"sync_jobs",
sa.Column("id", UUID(as_uuid=True), primary_key=True),
sa.Column("pharmacy_id", UUID(as_uuid=True), sa.ForeignKey("pharmacies.id"), nullable=False, index=True),
sa.Column("connector_id", UUID(as_uuid=True), sa.ForeignKey("connectors.id"), nullable=False, index=True),
sa.Column("strategy", sa.String(20), nullable=False),
sa.Column("status", sa.String(20), default="pending", index=True),
sa.Column("started_at", sa.DateTime(timezone=True)),
sa.Column("completed_at", sa.DateTime(timezone=True)),
sa.Column("items_total", sa.Integer),
sa.Column("items_processed", sa.Integer),
sa.Column("items_failed", sa.Integer),
sa.Column("error_message", sa.Text),
sa.Column("metadata", JSONB),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), onupdate=sa.func.now(), nullable=False),
)
op.create_table(
"sync_logs",
sa.Column("id", UUID(as_uuid=True), primary_key=True),
sa.Column("sync_job_id", UUID(as_uuid=True), sa.ForeignKey("sync_jobs.id"), nullable=False, index=True),
sa.Column("level", sa.String(20), default="INFO", nullable=False),
sa.Column("message", sa.Text, nullable=False),
sa.Column("details", JSONB),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False, index=True),
)
op.create_table(
"audit_logs",
sa.Column("id", UUID(as_uuid=True), primary_key=True),
sa.Column("actor_id", UUID(as_uuid=True), index=True),
sa.Column("actor_type", sa.String(50)),
sa.Column("action", sa.String(100), nullable=False, index=True),
sa.Column("resource_type", sa.String(100), nullable=False, index=True),
sa.Column("resource_id", sa.String(255), index=True),
sa.Column("old_value", JSONB),
sa.Column("new_value", JSONB),
sa.Column("ip_address", sa.String(45)),
sa.Column("user_agent", sa.String(500)),
sa.Column("request_id", sa.String(100), index=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False, index=True),
)
op.create_index("ix_audit_logs_action_resource", "audit_logs", ["action", "resource_type"])
op.create_index("ix_audit_logs_actor_created", "audit_logs", ["actor_id", "created_at"])
def downgrade() -> None:
op.drop_table("audit_logs")
op.drop_table("sync_logs")
op.drop_table("sync_jobs")
op.drop_table("reservations")
op.drop_table("api_keys")
op.drop_table("api_clients")
op.drop_table("technical_users")
op.drop_table("inventory_history")
op.drop_table("inventory_records")
op.drop_table("medication_catalog")
op.drop_table("medications")
op.drop_table("connector_configs")
op.drop_table("connector_versions")
op.drop_column("pharmacies", "connector_id")
op.drop_table("connectors")
op.drop_table("pharmacies")
op.drop_table("erp_systems")
+114
View File
@@ -0,0 +1,114 @@
services:
pip-api:
build:
context: .
dockerfile: Dockerfile
restart: unless-stopped
ports:
- "8000:8000"
environment:
DATABASE_URL: postgresql+asyncpg://pip:${PG_PASSWORD:-pip-secret}@postgres:5432/pip
REDIS_URL: redis://redis:6379/0
RABBITMQ_URL: amqp://pip:${RABBITMQ_PASSWORD:-pip-secret}@rabbitmq:5672/pip
JWT_SECRET_KEY: ${JWT_SECRET_KEY:-change-me-in-production}
NODE_ENV: production
LOG_LEVEL: INFO
LOG_JSON_FORMAT: "true"
CORS_ORIGINS: '["*"]'
OTEL_EXPORTER_OTLP_ENDPOINT: http://host.docker.internal:4317
OTEL_TRACES_ENABLED: "true"
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
rabbitmq:
condition: service_healthy
healthcheck:
test: ["CMD", "python", "-c", "import httpx; r = httpx.get('http://localhost:8000/api/v1/system/health'); exit(0 if r.status_code == 200 else 1)"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
deploy:
resources:
limits:
cpus: "2.0"
memory: 1G
reservations:
cpus: "0.5"
memory: 256M
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: pip
POSTGRES_USER: pip
POSTGRES_PASSWORD: ${PG_PASSWORD:-pip-secret}
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U pip -d pip"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
command: >
postgres
-c max_connections=200
-c shared_buffers=256MB
-c effective_cache_size=768MB
-c work_mem=4MB
-c maintenance_work_mem=64MB
-c checkpoint_completion_target=0.9
-c wal_buffers=16MB
-c default_statistics_target=100
-c random_page_cost=1.1
-c effective_io_concurrency=200
-c max_worker_processes=8
redis:
image: redis:7-alpine
restart: unless-stopped
ports:
- "6380:6379"
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
command: >
redis-server
--maxmemory 256mb
--maxmemory-policy allkeys-lru
--appendonly yes
--appendfsync everysec
rabbitmq:
image: rabbitmq:3-management-alpine
restart: unless-stopped
environment:
RABBITMQ_DEFAULT_USER: pip
RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASSWORD:-pip-secret}
RABBITMQ_DEFAULT_VHOST: pip
ports:
- "5672:5672"
- "15672:15672"
volumes:
- rabbitmq_data:/var/lib/rabbitmq
healthcheck:
test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"]
interval: 30s
timeout: 10s
retries: 5
start_period: 30s
volumes:
postgres_data:
redis_data:
rabbitmq_data:
+122
View File
@@ -0,0 +1,122 @@
services:
pip-api:
build:
context: .
dockerfile: Dockerfile
restart: unless-stopped
ports:
- "8000:8000"
environment:
DATABASE_URL: postgresql+asyncpg://pip:${PG_PASSWORD:-pip-secret}@postgres:5432/pip
REDIS_URL: redis://redis:6379/0
RABBITMQ_URL: amqp://pip:${RABBITMQ_PASSWORD:-pip-secret}@rabbitmq:5672/pip
JWT_SECRET_KEY: ${JWT_SECRET_KEY:-change-me-in-production}
NODE_ENV: production
LOG_LEVEL: INFO
LOG_JSON_FORMAT: "true"
CORS_ORIGINS: '["*"]'
# Re-routed to the shared Grafana Alloy collector on srv84-macos.
# host.docker.internal resolves to the Docker host gateway from
# inside the container — works because this stack runs on the same
# host as the monitoring stack.
OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-http://host.docker.internal:4317}
OTEL_TRACES_ENABLED: "true"
extra_hosts:
# `host-gateway` is the standard Docker engine alias for the bridge
# gateway (host.docker.internal is mac/Windows-native; this covers bare Linux).
- "host.docker.internal:host-gateway"
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
rabbitmq:
condition: service_healthy
healthcheck:
test: ["CMD", "python", "-c", "import httpx; r = httpx.get('http://localhost:8000/api/v1/system/health'); exit(0 if r.status_code == 200 else 1)"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
deploy:
resources:
limits:
cpus: "2.0"
memory: 1G
reservations:
cpus: "0.5"
memory: 256M
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: pip
POSTGRES_USER: pip
POSTGRES_PASSWORD: ${PG_PASSWORD:-pip-secret}
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U pip -d pip"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
command: >
postgres
-c max_connections=200
-c shared_buffers=256MB
-c effective_cache_size=768MB
-c work_mem=4MB
-c maintenance_work_mem=64MB
-c checkpoint_completion_target=0.9
-c wal_buffers=16MB
-c default_statistics_target=100
-c random_page_cost=1.1
-c effective_io_concurrency=200
-c max_worker_processes=8
redis:
image: redis:7-alpine
restart: unless-stopped
ports:
- "6380:6379"
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
command: >
redis-server
--maxmemory 256mb
--maxmemory-policy allkeys-lru
--appendonly yes
--appendfsync everysec
rabbitmq:
image: rabbitmq:3-management-alpine
restart: unless-stopped
environment:
RABBITMQ_DEFAULT_USER: pip
RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASSWORD:-pip-secret}
RABBITMQ_DEFAULT_VHOST: pip
ports:
- "5672:5672"
- "15672:15672"
volumes:
- rabbitmq_data:/var/lib/rabbitmq
healthcheck:
test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"]
interval: 30s
timeout: 10s
retries: 5
start_period: 30s
volumes:
postgres_data:
redis_data:
rabbitmq_data:
+41
View File
@@ -0,0 +1,41 @@
#!/usr/bin/env python
"""Run Alembic migrations before starting the app.
This script is designed as a Docker entrypoint pre-migration step.
It runs `alembic upgrade head` and then starts uvicorn.
"""
import subprocess
import sys
import os
def run_migrations():
db_url = os.environ.get("DATABASE_URL_SYNC") or os.environ.get("DATABASE_URL", "").replace("+asyncpg", "+psycopg2", 1)
if not db_url:
print("WARNING: No DATABASE_URL found, skipping migrations")
return
env = os.environ.copy()
env["DATABASE_URL_SYNC"] = db_url
result = subprocess.run(
["alembic", "-c", "alembic.ini", "upgrade", "head"],
env=env,
cwd="/app",
capture_output=True,
text=True,
)
if result.returncode != 0:
print(f"Migration failed:\n{result.stdout}\n{result.stderr}")
sys.exit(1)
print(f"Migration successful:\n{result.stdout}")
if __name__ == "__main__":
run_migrations()
os.execvp(
"uvicorn",
["uvicorn", "src.main:app", "--host", "0.0.0.0", "--port", "8000",
"--workers", "4", "--loop", "uvloop", "--http", "httptools"],
)
@@ -0,0 +1,18 @@
# Reference scrape config used by the *external* Prometheus on srv84-macos.
# This stack no longer ships its own Prometheus/Grafana — see PLAN.md and
# STATE.md for the external-integration rationale. Kept as documentation.
#
# To scrape `pip-api` directly (e.g. from a local Prometheus for debugging),
# point a static target at `host.docker.internal:8000` instead of `pip-api:8000`.
global:
scrape_interval: 15s
evaluation_interval: 15s
scrape_configs:
- job_name: "pip-platform"
metrics_path: /metrics
static_configs:
- targets: ["host.docker.internal:8000"]
labels:
host: srv84-macos
role: pip-platform
+69
View File
@@ -0,0 +1,69 @@
[project]
name = "pip-platform"
version = "0.1.0"
description = "Pharmacy Integration Platform — unified API between pharmacy ERPs and external applications"
requires-python = ">=3.13"
dependencies = [
"fastapi>=0.115.0",
"uvicorn[standard]>=0.32.0",
"sqlalchemy[asyncio]>=2.0.36",
"asyncpg>=0.30.0",
"alembic>=1.14.0",
"pydantic[email]>=2.10.0",
"pydantic-settings>=2.7.0",
"python-jose[cryptography]>=3.3.0",
"passlib[bcrypt]>=1.7.4",
"bcrypt>=4.2.0",
"python-multipart>=0.0.18",
"redis[hiredis]>=5.2.0",
"aio-pika>=9.6.0",
"opentelemetry-api>=1.29.0",
"opentelemetry-sdk>=1.29.0",
"opentelemetry-instrumentation-fastapi>=0.50b0",
"opentelemetry-instrumentation-sqlalchemy>=0.50b0",
"opentelemetry-instrumentation-redis>=0.50b0",
"opentelemetry-exporter-otlp>=1.29.0",
"prometheus-client>=0.21.0",
"structlog>=24.4.0",
"httpx>=0.28.0",
"click>=8.1.0",
"psycopg2-binary>=2.9.9",
]
[project.optional-dependencies]
dev = [
"pytest>=8.3.0",
"pytest-asyncio>=0.24.0",
"pytest-cov>=6.0.0",
"httpx>=0.28.0",
"ruff>=0.8.0",
"mypy>=1.13.0",
"testcontainers[postgres,redis,rabbitmq]>=4.8.0",
]
[tool.ruff]
target-version = "py313"
line-length = 120
[tool.ruff.lint]
select = ["E", "F", "I", "N", "W", "UP", "B", "SIM", "TCH", "RUF"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
addopts = "-v --tb=short"
[tool.mypy]
python_version = "3.13"
strict = true
plugins = ["pydantic.mypy"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src"]
[project.scripts]
pip = "src.cli:cli"
View File
+3
View File
@@ -0,0 +1,3 @@
from .main import app
__all__ = ["app"]
View File
+91
View File
@@ -0,0 +1,91 @@
from __future__ import annotations
import time
import uuid
from typing import Any
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
from starlette.requests import Request
from starlette.responses import Response
from src.core.logging import get_logger
from src.domain.entities import AuditLogEntity
from src.domain.interfaces import IAuditLogRepository
from src.infrastructure.database.session import async_session_factory
logger = get_logger("audit_middleware")
WRITE_METHODS = {"POST", "PUT", "PATCH", "DELETE"}
IGNORED_PATHS = {"/docs", "/redoc", "/openapi.json", "/metrics", "/api/v1/system/health"}
class AuditMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
response = await call_next(request)
if request.method in WRITE_METHODS and not self._should_ignore(request.url.path):
try:
await self._record_audit(request, response)
except Exception as exc:
logger.warning("audit_log_failed", extra={"error": str(exc)})
return response
def _should_ignore(self, path: str) -> bool:
return any(path.startswith(p) for p in IGNORED_PATHS)
async def _record_audit(self, request: Request, response: Response) -> None:
actor_id = None
actor_type = "anonymous"
if hasattr(request.state, "user") and request.state.user:
user = request.state.user
try:
actor_id = uuid.UUID(user.subject)
except (ValueError, TypeError):
pass
actor_type = user.role.value if hasattr(user.role, "value") else str(user.role)
resource_type = self._extract_resource_type(request.url.path)
action = f"{request.method.lower()}_{resource_type}"
resource_id = self._extract_resource_id(request.url.path)
ip_address = request.client.host if request.client else None
user_agent = request.headers.get("user-agent", "")
request_id = getattr(request.state, "request_id", str(uuid.uuid4()))
async with async_session_factory() as session:
try:
from src.infrastructure.database.repositories import AuditLogRepository
repo = AuditLogRepository(session)
entity = AuditLogEntity(
actor_id=actor_id,
actor_type=actor_type,
action=action,
resource_type=resource_type,
resource_id=resource_id,
ip_address=ip_address,
user_agent=user_agent,
request_id=request_id,
)
await repo.add(entity)
await session.commit()
except Exception as exc:
logger.warning("audit_db_write_failed", extra={"error": str(exc)})
def _extract_resource_type(self, path: str) -> str:
parts = path.strip("/").split("/")
if len(parts) >= 3:
return parts[2]
if len(parts) >= 1:
return parts[-1]
return "unknown"
def _extract_resource_id(self, path: str) -> str | None:
parts = path.strip("/").split("/")
for part in reversed(parts[3:] if len(parts) > 3 else parts):
try:
uuid.UUID(part)
return part
except ValueError:
continue
return None
@@ -0,0 +1,36 @@
import time
import uuid
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
from starlette.requests import Request
from starlette.responses import Response
from src.core.logging import get_logger
logger = get_logger("request_logging")
class RequestLoggingMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
request_id = request.headers.get("X-Request-ID", str(uuid.uuid4()))
request.state.request_id = request_id
start = time.perf_counter()
response = await call_next(request)
duration_ms = (time.perf_counter() - start) * 1000
logger.info(
"request_completed",
extra={
"request_id": request_id,
"method": request.method,
"path": request.url.path,
"status_code": response.status_code,
"duration_ms": round(duration_ms, 2),
"client_ip": request.client.host if request.client else None,
"user_agent": request.headers.get("user-agent", ""),
},
)
response.headers["X-Request-ID"] = request_id
return response
@@ -0,0 +1,54 @@
import time
from fastapi import Request, Response
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
from src.infrastructure.config.settings import settings
class RateLimitMiddleware(BaseHTTPMiddleware):
def __init__(self, app, requests_per_minute: int | None = None):
super().__init__(app)
self._rpm = requests_per_minute or settings.RATE_LIMIT_PER_MINUTE
self._clients: dict[str, list[float]] = {}
async def dispatch(
self, request: Request, call_next: RequestResponseEndpoint
) -> Response:
if request.method == "OPTIONS":
return await call_next(request)
path = request.url.path
exempt_prefixes = ("/docs", "/redoc", "/openapi", "/metrics", "/api/v1/system/health")
if any(path.startswith(p) for p in exempt_prefixes):
return await call_next(request)
client_key = self._get_client_key(request)
now = time.monotonic()
window = self._clients.setdefault(client_key, [])
window[:] = [t for t in window if now - t < 60]
if len(window) >= self._rpm:
return Response(
content='{"error":{"code":"RATE_LIMIT_EXCEEDED","message":"Rate limit exceeded. Try again later."}}',
status_code=429,
media_type="application/json",
headers={"Retry-After": "60"},
)
window.append(now)
response = await call_next(request)
remaining = self._rpm - len(window)
response.headers["X-RateLimit-Limit"] = str(self._rpm)
response.headers["X-RateLimit-Remaining"] = str(max(0, remaining))
response.headers["X-RateLimit-Reset"] = str(int(time.time()) + 60)
return response
@staticmethod
def _get_client_key(request: Request) -> str:
forwarded = request.headers.get("x-forwarded-for")
if forwarded:
return forwarded.split(",")[0].strip()
if request.client:
return request.client.host
return "unknown"
View File
@@ -0,0 +1,107 @@
import uuid
from fastapi import Depends, HTTPException, Request, status
from fastapi.security import APIKeyHeader, HTTPAuthorizationCredentials, HTTPBearer
from src.core.exceptions import ForbiddenException, UnauthorizedException
from src.domain.entities import UserRole
from src.infrastructure.database.session import AsyncSession, get_db_session
from src.services.auth.auth_service import AuthService
from src.services.auth.security import RBACGuard, TokenData, decode_token, validate_token_type
from src.infrastructure.database.repositories import APIClientRepository, APIKeyRepository
from src.services.auth.security import verify_api_key
_bearer_scheme = HTTPBearer(auto_error=False)
_api_key_scheme = APIKeyHeader(name="X-API-Key", auto_error=False)
async def get_auth_service(session: AsyncSession = Depends(get_db_session)) -> AuthService:
return AuthService(session)
async def get_api_key_user(
api_key: str | None = Depends(_api_key_scheme),
session: AsyncSession = Depends(get_db_session),
) -> TokenData | None:
if not api_key:
return None
prefix = api_key[:8]
key_repo = APIKeyRepository(session)
key_entity = await key_repo.get_by_prefix(prefix)
if not key_entity or not key_entity.is_active:
return None
from datetime import datetime, timezone
if key_entity.expires_at and key_entity.expires_at < datetime.now(timezone.utc):
return None
if not verify_api_key(api_key, key_entity.key_hash):
return None
client_repo = APIClientRepository(session)
client = await client_repo.get_by_id(key_entity.api_client_id)
if not client or not client.is_active:
return None
await key_repo.update_last_used(key_entity.id)
return TokenData({
"sub": str(client.id),
"role": "api_client",
"scopes": client.scopes or [],
"iss": "pip-platform",
"type": "access",
"jti": str(key_entity.id),
})
async def get_current_user(
request: Request,
credentials: HTTPAuthorizationCredentials | None = Depends(_bearer_scheme),
api_key_user: TokenData | None = Depends(get_api_key_user),
) -> TokenData:
if credentials:
try:
payload = decode_token(credentials.credentials)
validate_token_type(payload, expected_type="access")
user = TokenData(payload)
request.state.user = user
return user
except UnauthorizedException:
raise
except Exception as e:
raise UnauthorizedException(f"Invalid token: {e}") from e
if api_key_user:
request.state.user = api_key_user
return api_key_user
raise UnauthorizedException("Authentication required. Provide Bearer token or X-API-Key header.")
def get_current_user_optional(
credentials: HTTPAuthorizationCredentials | None = Depends(_bearer_scheme),
) -> TokenData | None:
if not credentials:
return None
try:
payload = decode_token(credentials.credentials)
validate_token_type(payload, expected_type="access")
return TokenData(payload)
except Exception:
return None
def require_role(role: UserRole):
def _checker(current_user: TokenData = Depends(get_current_user)) -> TokenData:
RBACGuard.require_role(current_user, role)
return current_user
return _checker
def require_scope(scope: str):
def _checker(current_user: TokenData = Depends(get_current_user)) -> TokenData:
RBACGuard.require_scope(current_user, scope)
return current_user
return _checker
@@ -0,0 +1,157 @@
from datetime import datetime
from uuid import UUID
import hashlib
import secrets
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from src.core.exceptions import ConflictException, NotFoundException
from src.domain.entities import APIClientEntity, APIKeyEntity
from src.infrastructure.database.repositories import APIClientRepository, APIKeyRepository
from src.infrastructure.database.session import get_db_session
from src.api.v1.dependencies.auth import get_current_user, require_role
from src.domain.entities import UserRole
from src.services.auth.security import TokenData, generate_api_key
router = APIRouter(prefix="/api-clients", tags=["API Clients"])
class APIClientCreateRequest(BaseModel):
name: str = Field(..., min_length=1, max_length=255)
description: str | None = None
scopes: list[str] = Field(default_factory=lambda: ["pharmacies:read", "medications:read", "inventory:read"])
rate_limit: int | None = None
allowed_ips: list[str] = Field(default_factory=list)
class APIClientResponse(BaseModel):
id: UUID
name: str
description: str | None
owner_id: UUID | None
is_active: bool
scopes: list[str]
rate_limit: int | None
allowed_ips: list[str]
created_at: datetime | None
updated_at: datetime | None
class APIKeyCreateRequest(BaseModel):
name: str = Field(..., min_length=1, max_length=255)
expires_at: datetime | None = None
class APIKeyResponse(BaseModel):
id: UUID
name: str
key_prefix: str
is_active: bool
expires_at: datetime | None
last_used_at: datetime | None
created_at: datetime | None
class APIKeyCreateResponse(BaseModel):
id: UUID
name: str
key: str
key_prefix: str
expires_at: datetime | None
class APIClientListResponse(BaseModel):
items: list[APIClientResponse]
total: int
offset: int
limit: int
@router.get("", response_model=APIClientListResponse)
async def list_api_clients(
is_active: bool | None = None,
offset: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
):
repo = APIClientRepository(session)
entities, total = await repo.list_clients(is_active=is_active, offset=offset, limit=limit)
items = [
APIClientResponse(
id=e.id, name=e.name, description=e.description, owner_id=e.owner_id,
is_active=e.is_active, scopes=e.scopes, rate_limit=e.rate_limit,
allowed_ips=e.allowed_ips, created_at=e.created_at, updated_at=e.updated_at,
)
for e in entities
]
return APIClientListResponse(items=items, total=total, offset=offset, limit=limit)
@router.post("", response_model=APIClientResponse, status_code=201)
async def create_api_client(
body: APIClientCreateRequest,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
):
repo = APIClientRepository(session)
entity = APIClientEntity(
name=body.name, description=body.description,
owner_id=UUID(current_user.subject), is_active=True,
scopes=body.scopes, rate_limit=body.rate_limit,
allowed_ips=body.allowed_ips,
)
created = await repo.create(entity)
return APIClientResponse(
id=created.id, name=created.name, description=created.description,
owner_id=created.owner_id, is_active=created.is_active,
scopes=created.scopes, rate_limit=created.rate_limit,
allowed_ips=created.allowed_ips, created_at=created.created_at,
updated_at=created.updated_at,
)
@router.post("/{client_id}/keys", response_model=APIKeyCreateResponse, status_code=201)
async def create_api_key(
client_id: UUID,
body: APIKeyCreateRequest,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
):
client_repo = APIClientRepository(session)
client = await client_repo.get_by_id(client_id)
if not client:
raise NotFoundException("API Client", str(client_id))
raw_key, prefix, key_hash = generate_api_key()
key_repo = APIKeyRepository(session)
key_entity = APIKeyEntity(
api_client_id=client_id, key_prefix=prefix,
key_hash=key_hash, name=body.name,
is_active=True, expires_at=body.expires_at,
)
created = await key_repo.create(key_entity)
return APIKeyCreateResponse(
id=created.id, name=created.name, key=raw_key,
key_prefix=created.key_prefix, expires_at=created.expires_at,
)
@router.get("/{client_id}/keys", response_model=list[APIKeyResponse])
async def list_api_keys(
client_id: UUID,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
):
key_repo = APIKeyRepository(session)
keys = await key_repo.list_by_client(client_id)
return [
APIKeyResponse(
id=k.id, name=k.name, key_prefix=k.key_prefix,
is_active=k.is_active, expires_at=k.expires_at,
last_used_at=k.last_used_at, created_at=k.created_at,
)
for k in keys
]
@@ -0,0 +1,70 @@
from datetime import datetime
from uuid import UUID
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from src.api.v1.dependencies.auth import get_current_user, require_role
from src.domain.entities import UserRole
from src.infrastructure.database.repositories import AuditLogRepository
from src.infrastructure.database.session import get_db_session
from src.services.auth.security import TokenData
from src.services.audit.audit_service import AuditService
router = APIRouter(prefix="/audit", tags=["Audit"])
class AuditLogResponse(BaseModel):
id: UUID
actor_id: UUID | None
actor_type: str | None
action: str
resource_type: str
resource_id: str | None
old_value: dict | None
new_value: dict | None
ip_address: str | None
user_agent: str | None
request_id: str | None
created_at: datetime | None
class AuditLogListResponse(BaseModel):
items: list[AuditLogResponse]
total: int
offset: int
limit: int
def _get_service(session: AsyncSession) -> AuditService:
return AuditService(AuditLogRepository(session))
@router.get("/logs", response_model=AuditLogListResponse)
async def list_audit_logs(
actor_id: UUID | None = None,
action: str | None = None,
resource_type: str | None = None,
offset: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
):
service = _get_service(session)
entities, total = await service.list_logs(
actor_id=actor_id, action=action, resource_type=resource_type,
offset=offset, limit=limit,
)
items = [
AuditLogResponse(
id=e.id, actor_id=e.actor_id, actor_type=e.actor_type,
action=e.action, resource_type=e.resource_type,
resource_id=e.resource_id, old_value=e.old_value,
new_value=e.new_value, ip_address=e.ip_address,
user_agent=e.user_agent, request_id=e.request_id,
created_at=e.created_at,
)
for e in entities
]
return AuditLogListResponse(items=items, total=total, offset=offset, limit=limit)
+116
View File
@@ -0,0 +1,116 @@
from datetime import datetime, timezone
from typing import Any
from uuid import UUID
from fastapi import APIRouter, Depends, Request
from pydantic import BaseModel, EmailStr, Field
from sqlalchemy.ext.asyncio import AsyncSession
from src.domain.entities import UserRole
from src.infrastructure.database.session import get_db_session
from src.services.auth.auth_service import AuthService
from src.api.v1.dependencies.auth import get_auth_service, get_current_user, require_role
from src.services.auth.security import TokenData
router = APIRouter(prefix="/auth", tags=["Authentication"])
class LoginRequest(BaseModel):
username: str = Field(..., min_length=3, max_length=100)
password: str = Field(..., min_length=8)
class RegisterRequest(BaseModel):
username: str = Field(..., min_length=3, max_length=100, pattern=r"^[A-Za-z0-9_]+$")
password: str = Field(..., min_length=8)
email: EmailStr | None = None
full_name: str | None = None
role: UserRole = UserRole.VIEWER
class TokenResponse(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
expires_in: int
class RefreshRequest(BaseModel):
refresh_token: str
class UserResponse(BaseModel):
id: UUID
username: str
email: str | None
full_name: str | None
role: str
is_active: bool
scopes: list[str]
last_login_at: datetime | None
created_at: datetime | None
class MeResponse(BaseModel):
id: UUID
username: str
email: str | None
full_name: str | None
role: str
is_active: bool
scopes: list[str]
@router.post("/register", response_model=UserResponse, status_code=201)
async def register(body: RegisterRequest, service: AuthService = Depends(get_auth_service)):
user = await service.register(
username=body.username,
password=body.password,
email=body.email,
full_name=body.full_name,
role=body.role,
)
return UserResponse(
id=user.id,
username=user.username,
email=user.email,
full_name=user.full_name,
role=user.role.value,
is_active=user.is_active,
scopes=user.scopes,
last_login_at=user.last_login_at,
created_at=user.created_at,
)
@router.post("/token", response_model=TokenResponse)
async def login(body: LoginRequest, service: AuthService = Depends(get_auth_service)):
user, access_token, refresh_token = await service.authenticate(body.username, body.password)
return TokenResponse(
access_token=access_token,
refresh_token=refresh_token,
expires_in=30 * 60,
)
@router.post("/refresh", response_model=TokenResponse)
async def refresh_token(body: RefreshRequest, service: AuthService = Depends(get_auth_service)):
access_token, refresh_token = await service.refresh_access_token(body.refresh_token)
return TokenResponse(
access_token=access_token,
refresh_token=refresh_token,
expires_in=30 * 60,
)
@router.get("/me", response_model=MeResponse)
async def get_me(current_user: TokenData = Depends(get_current_user)):
return MeResponse(
id=UUID(current_user.subject),
username="",
email=None,
full_name=None,
role=current_user.role.value,
is_active=True,
scopes=current_user.scopes,
)
@@ -0,0 +1,106 @@
from datetime import datetime
from typing import Any
from uuid import UUID
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from src.api.v1.dependencies.auth import get_current_user, require_role
from src.core.exceptions import NotFoundException
from src.domain.entities import MedicationCatalogEntryEntity, UserRole
from src.infrastructure.database.repositories import MedicationCatalogRepository, MedicationRepository
from src.infrastructure.database.session import get_db_session
from src.services.auth.security import TokenData
from src.services.medication.medication_service import MedicationService
router = APIRouter(prefix="/pharmacies/{pharmacy_id}/catalog", tags=["Medication Catalog"])
class CatalogEntryUpsertRequest(BaseModel):
medication_id: UUID
price: float | None = None
stock: int = 0
is_available: bool = False
last_verified_at: datetime | None = None
metadata: dict[str, Any] | None = None
class CatalogEntryResponse(BaseModel):
id: UUID
pharmacy_id: UUID
medication_id: UUID
price: float | None
stock: int
is_available: bool
last_verified_at: datetime | None
metadata: dict[str, Any] | None
created_at: datetime | None
updated_at: datetime | None
class CatalogListResponse(BaseModel):
items: list[CatalogEntryResponse]
total: int
offset: int
limit: int
def _entity_to_response(e: MedicationCatalogEntryEntity) -> CatalogEntryResponse:
return CatalogEntryResponse(
id=e.id, pharmacy_id=e.pharmacy_id, medication_id=e.medication_id,
price=e.price, stock=e.stock, is_available=e.is_available,
last_verified_at=e.last_verified_at, metadata=e.metadata_,
created_at=e.created_at, updated_at=e.updated_at,
)
def _get_service(session: AsyncSession) -> MedicationService:
return MedicationService(
medication_repo=MedicationRepository(session),
catalog_repo=MedicationCatalogRepository(session),
)
@router.get("", response_model=CatalogListResponse)
async def list_catalog(
pharmacy_id: UUID,
offset: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(get_current_user),
):
service = _get_service(session)
entities, total = await service.list_catalog(pharmacy_id, offset=offset, limit=limit)
return CatalogListResponse(items=[_entity_to_response(e) for e in entities], total=total, offset=offset, limit=limit)
@router.get("/{medication_id}", response_model=CatalogEntryResponse)
async def get_catalog_entry(
pharmacy_id: UUID,
medication_id: UUID,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(get_current_user),
):
service = _get_service(session)
entity = await service.get_catalog_entry(pharmacy_id, medication_id)
if not entity:
raise NotFoundException("CatalogEntry", f"{pharmacy_id}/{medication_id}")
return _entity_to_response(entity)
@router.put("", response_model=CatalogEntryResponse)
async def upsert_catalog_entry(
pharmacy_id: UUID,
body: CatalogEntryUpsertRequest,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(require_role(UserRole.OPERATOR)),
):
service = _get_service(session)
entity = MedicationCatalogEntryEntity(
pharmacy_id=pharmacy_id, medication_id=body.medication_id,
price=body.price, stock=body.stock, is_available=body.is_available,
last_verified_at=body.last_verified_at, metadata_=body.metadata,
)
upserted = await service.upsert_catalog_entry(entity)
return _entity_to_response(upserted)
@@ -0,0 +1,280 @@
from __future__ import annotations
from datetime import datetime
from typing import Any
from uuid import UUID
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from src.api.v1.dependencies.auth import get_current_user, require_role
from src.connectors.base import ConnectorResult
from src.core.exceptions import NotFoundException, ValidationException
from src.domain.entities import ConnectorConfigEntity, ConnectorEntity, ConnectorStatus, ERPType, UserRole
from src.infrastructure.database.repositories import ConnectorConfigRepository, ConnectorRepository
from src.infrastructure.database.session import get_db_session
from src.services.auth.security import TokenData
from src.services.connector_service import ConnectorService
router = APIRouter(prefix="/connectors", tags=["Connectors"])
class ConnectorCreateRequest(BaseModel):
erp_system_id: UUID
pharmacy_id: UUID
name: str = Field(..., min_length=1, max_length=255)
connector_type: str
version: str = "1.0.0"
config: dict[str, Any] | None = None
metadata: dict[str, Any] | None = None
class ConnectorUpdateRequest(BaseModel):
name: str | None = Field(None, min_length=1, max_length=255)
version: str | None = None
config: dict[str, Any] | None = None
status: str | None = None
metadata: dict[str, Any] | None = None
class ConnectorConfigRequest(BaseModel):
key: str = Field(..., min_length=1, max_length=100)
value: str
encrypted: bool = False
description: str | None = None
class ConnectorResponse(BaseModel):
id: UUID
erp_system_id: UUID
pharmacy_id: UUID
name: str
connector_type: str
version: str
config: dict[str, Any] | None
status: str
last_heartbeat_at: datetime | None
last_error: str | None
metadata: dict[str, Any] | None
created_at: datetime | None
updated_at: datetime | None
class ConnectorListResponse(BaseModel):
items: list[ConnectorResponse]
total: int
offset: int
limit: int
class ConnectorConfigResponse(BaseModel):
id: UUID
connector_id: UUID
key: str
value: str
encrypted: bool
description: str | None
created_at: datetime | None
updated_at: datetime | None
class ConnectorTestResponse(BaseModel):
success: bool
data: dict[str, Any] | None = None
error: str | None = None
class ConnectorDeployResponse(BaseModel):
id: UUID
status: str
message: str
def _entity_to_response(e: ConnectorEntity) -> ConnectorResponse:
return ConnectorResponse(
id=e.id, erp_system_id=e.erp_system_id, pharmacy_id=e.pharmacy_id,
name=e.name, connector_type=e.connector_type, version=e.version,
config=e.config, status=e.status, last_heartbeat_at=e.last_heartbeat_at,
last_error=e.last_error, metadata=e.metadata_, created_at=e.created_at,
updated_at=e.updated_at,
)
def _config_entity_to_response(e: ConnectorConfigEntity) -> ConnectorConfigResponse:
return ConnectorConfigResponse(
id=e.id, connector_id=e.connector_id, key=e.key, value=e.value,
encrypted=e.encrypted, description=e.description,
created_at=e.created_at, updated_at=e.updated_at,
)
def _get_service(session: AsyncSession) -> ConnectorService:
return ConnectorService(
connector_repo=ConnectorRepository(session),
config_repo=ConnectorConfigRepository(session),
)
@router.get("", response_model=ConnectorListResponse)
async def list_connectors(
erp_system_id: UUID | None = None,
pharmacy_id: UUID | None = None,
status: str | None = None,
offset: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(get_current_user),
):
service = _get_service(session)
entities, total = await service.list_connectors(erp_system_id=erp_system_id, pharmacy_id=pharmacy_id, status=status, offset=offset, limit=limit)
return ConnectorListResponse(items=[_entity_to_response(e) for e in entities], total=total, offset=offset, limit=limit)
@router.get("/types", response_model=list[str])
async def list_connector_types(
current_user: TokenData = Depends(get_current_user),
):
from src.connectors.registry import connector_registry
return connector_registry.list_registered_types()
@router.get("/{connector_id}", response_model=ConnectorResponse)
async def get_connector(
connector_id: UUID,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(get_current_user),
):
service = _get_service(session)
entity = await service.get_connector(connector_id)
if not entity:
raise NotFoundException("Connector", str(connector_id))
return _entity_to_response(entity)
@router.post("", response_model=ConnectorResponse, status_code=201)
async def create_connector(
body: ConnectorCreateRequest,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
):
service = _get_service(session)
entity = ConnectorEntity(
erp_system_id=body.erp_system_id, pharmacy_id=body.pharmacy_id,
name=body.name, connector_type=body.connector_type,
version=body.version, config=body.config,
status=ConnectorStatus.PENDING, metadata_=body.metadata,
)
created = await service.create_connector(entity)
return _entity_to_response(created)
@router.put("/{connector_id}", response_model=ConnectorResponse)
async def update_connector(
connector_id: UUID,
body: ConnectorUpdateRequest,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
):
service = _get_service(session)
entity = await service.get_connector(connector_id)
if not entity:
raise NotFoundException("Connector", str(connector_id))
update_data = body.model_dump(exclude_unset=True)
if "metadata" in update_data:
entity.metadata_ = update_data.pop("metadata")
for key, value in update_data.items():
setattr(entity, key, value)
updated = await service.update_connector(entity)
return _entity_to_response(updated)
@router.delete("/{connector_id}", status_code=204)
async def delete_connector(
connector_id: UUID,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
):
service = _get_service(session)
deleted = await service.delete_connector(connector_id)
if not deleted:
raise NotFoundException("Connector", str(connector_id))
@router.post("/{connector_id}/test", response_model=ConnectorTestResponse)
async def test_connector(
connector_id: UUID,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(require_role(UserRole.OPERATOR)),
):
service = _get_service(session)
result = await service.test_connection(connector_id)
return ConnectorTestResponse(success=result.success, data=result.data, error=result.error)
@router.post("/{connector_id}/deploy", response_model=ConnectorDeployResponse)
async def deploy_connector(
connector_id: UUID,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
):
service = _get_service(session)
entity = await service.deploy_connector(connector_id)
return ConnectorDeployResponse(id=entity.id, status=entity.status, message="Connector deployed successfully")
@router.post("/{connector_id}/deactivate", response_model=ConnectorDeployResponse)
async def deactivate_connector(
connector_id: UUID,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
):
service = _get_service(session)
entity = await service.deactivate_connector(connector_id)
return ConnectorDeployResponse(id=entity.id, status=entity.status, message="Connector deactivated successfully")
@router.get("/{connector_id}/configs", response_model=list[ConnectorConfigResponse])
async def list_connector_configs(
connector_id: UUID,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(get_current_user),
):
service = _get_service(session)
connector = await service.get_connector(connector_id)
if not connector:
raise NotFoundException("Connector", str(connector_id))
configs = await service.get_configs(connector_id)
return [_config_entity_to_response(c) for c in configs]
@router.put("/{connector_id}/configs", response_model=ConnectorConfigResponse)
async def upsert_connector_config(
connector_id: UUID,
body: ConnectorConfigRequest,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
):
service = _get_service(session)
connector = await service.get_connector(connector_id)
if not connector:
raise NotFoundException("Connector", str(connector_id))
cfg_entity = ConnectorConfigEntity(
connector_id=connector_id, key=body.key, value=body.value,
encrypted=body.encrypted, description=body.description,
)
created = await service.upsert_config(cfg_entity)
return _config_entity_to_response(created)
@router.delete("/{connector_id}/configs/{config_id}", status_code=204)
async def delete_connector_config(
connector_id: UUID,
config_id: UUID,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
):
service = _get_service(session)
await service.delete_config(config_id)
@@ -0,0 +1,143 @@
from datetime import datetime
from typing import Any
from uuid import UUID
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from src.api.v1.dependencies.auth import get_current_user, require_role
from src.core.exceptions import NotFoundException
from src.domain.entities import ERPSystemEntity, UserRole
from src.domain.interfaces import IERPSystemRepository
from src.infrastructure.database.repositories import ERPSystemRepository
from src.infrastructure.database.session import get_db_session
from src.services.auth.security import TokenData
router = APIRouter(prefix="/erp-systems", tags=["ERP Systems"])
class ERPSystemCreateRequest(BaseModel):
name: str = Field(..., min_length=1, max_length=255)
erp_type: str
version: str | None = None
base_url: str | None = None
auth_config: dict[str, Any] | None = None
metadata: dict[str, Any] | None = None
class ERPSystemUpdateRequest(BaseModel):
name: str | None = Field(None, min_length=1, max_length=255)
version: str | None = None
base_url: str | None = None
auth_config: dict[str, Any] | None = None
status: str | None = None
metadata: dict[str, Any] | None = None
class ERPSystemResponse(BaseModel):
id: UUID
name: str
erp_type: str
version: str | None
base_url: str | None
auth_config: dict[str, Any] | None
status: str
metadata: dict[str, Any] | None
created_at: datetime | None
updated_at: datetime | None
class ERPSystemListResponse(BaseModel):
items: list[ERPSystemResponse]
total: int
offset: int
limit: int
def _entity_to_response(e: ERPSystemEntity) -> ERPSystemResponse:
return ERPSystemResponse(
id=e.id, name=e.name, erp_type=e.erp_type, version=e.version,
base_url=e.base_url, auth_config=e.auth_config, status=e.status,
metadata=e.metadata_, created_at=e.created_at, updated_at=e.updated_at,
)
def _get_repo(session: AsyncSession) -> IERPSystemRepository:
return ERPSystemRepository(session)
@router.get("", response_model=ERPSystemListResponse)
async def list_erp_systems(
erp_type: str | None = None,
offset: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(get_current_user),
):
repo = _get_repo(session)
entities, total = await repo.list_erp_systems(erp_type=erp_type, offset=offset, limit=limit)
return ERPSystemListResponse(items=[_entity_to_response(e) for e in entities], total=total, offset=offset, limit=limit)
@router.get("/{erp_id}", response_model=ERPSystemResponse)
async def get_erp_system(
erp_id: UUID,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(get_current_user),
):
repo = _get_repo(session)
entity = await repo.get_by_id(erp_id)
if not entity:
raise NotFoundException("ERP System", str(erp_id))
return _entity_to_response(entity)
@router.post("", response_model=ERPSystemResponse, status_code=201)
async def create_erp_system(
body: ERPSystemCreateRequest,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
):
repo = _get_repo(session)
entity = ERPSystemEntity(
name=body.name, erp_type=body.erp_type, version=body.version,
base_url=body.base_url, auth_config=body.auth_config,
metadata_=body.metadata,
)
created = await repo.create(entity)
return _entity_to_response(created)
@router.put("/{erp_id}", response_model=ERPSystemResponse)
async def update_erp_system(
erp_id: UUID,
body: ERPSystemUpdateRequest,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
):
repo = _get_repo(session)
entity = await repo.get_by_id(erp_id)
if not entity:
raise NotFoundException("ERP System", str(erp_id))
update_data = body.model_dump(exclude_unset=True)
if "metadata" in update_data:
entity.metadata_ = update_data.pop("metadata")
for key, value in update_data.items():
setattr(entity, key, value)
updated = await repo.update(entity)
return _entity_to_response(updated)
@router.delete("/{erp_id}", status_code=204)
async def delete_erp_system(
erp_id: UUID,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
):
repo = _get_repo(session)
deleted = await repo.delete(erp_id)
if not deleted:
raise NotFoundException("ERP System", str(erp_id))
@@ -0,0 +1,150 @@
from datetime import datetime
from typing import Any
from uuid import UUID
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from src.api.v1.dependencies.auth import get_current_user, require_role
from src.core.exceptions import NotFoundException
from src.domain.entities import InventoryRecordEntity, UserRole
from src.infrastructure.database.repositories import InventoryRepository, ReservationRepository
from src.infrastructure.database.session import get_db_session
from src.services.auth.security import TokenData
from src.services.inventory.inventory_service import InventoryService
router = APIRouter(prefix="/pharmacies/{pharmacy_id}/inventory", tags=["Inventory"])
class InventoryUpsertRequest(BaseModel):
medication_id: UUID
stock: int = Field(..., ge=0)
reserved_stock: int = Field(0, ge=0)
available_stock: int = Field(0, ge=0)
min_stock: int | None = Field(None, ge=0)
max_stock: int | None = Field(None, ge=0)
last_updated_at: datetime | None = None
source: str | None = Field(None, max_length=100)
batch_id: str | None = Field(None, max_length=100)
metadata: dict[str, Any] | None = None
class InventoryRecordResponse(BaseModel):
id: UUID
pharmacy_id: UUID
medication_id: UUID
stock: int
reserved_stock: int
available_stock: int
min_stock: int | None
max_stock: int | None
last_updated_at: datetime | None
source: str | None
batch_id: str | None
metadata: dict[str, Any] | None
created_at: datetime | None
updated_at: datetime | None
class InventoryListResponse(BaseModel):
items: list[InventoryRecordResponse]
total: int
offset: int
limit: int
class InventoryHistoryResponse(BaseModel):
id: UUID
pharmacy_id: UUID
medication_id: UUID
previous_stock: int
new_stock: int
delta: int
reason: str | None
source: str | None
created_at: datetime | None
def _record_entity_to_response(e: InventoryRecordEntity) -> InventoryRecordResponse:
return InventoryRecordResponse(
id=e.id, pharmacy_id=e.pharmacy_id, medication_id=e.medication_id,
stock=e.stock, reserved_stock=e.reserved_stock, available_stock=e.available_stock,
min_stock=e.min_stock, max_stock=e.max_stock,
last_updated_at=e.last_updated_at, source=e.source, batch_id=e.batch_id,
metadata=e.metadata_, created_at=e.created_at, updated_at=e.updated_at,
)
def _get_service(session: AsyncSession) -> InventoryService:
return InventoryService(
inventory_repo=InventoryRepository(session),
reservation_repo=ReservationRepository(session),
)
@router.get("", response_model=InventoryListResponse)
async def list_inventory(
pharmacy_id: UUID,
offset: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(get_current_user),
):
service = _get_service(session)
entities, total = await service.list_inventory(pharmacy_id, offset=offset, limit=limit)
return InventoryListResponse(items=[_record_entity_to_response(e) for e in entities], total=total, offset=offset, limit=limit)
@router.get("/{medication_id}", response_model=InventoryRecordResponse)
async def get_inventory_record(
pharmacy_id: UUID,
medication_id: UUID,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(get_current_user),
):
service = _get_service(session)
entity = await service.get_inventory_record(pharmacy_id, medication_id)
if not entity:
raise NotFoundException("InventoryRecord", f"{pharmacy_id}/{medication_id}")
return _record_entity_to_response(entity)
@router.put("", response_model=InventoryRecordResponse)
async def upsert_inventory_record(
pharmacy_id: UUID,
body: InventoryUpsertRequest,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(require_role(UserRole.OPERATOR)),
):
service = _get_service(session)
entity = InventoryRecordEntity(
pharmacy_id=pharmacy_id, medication_id=body.medication_id,
stock=body.stock, reserved_stock=body.reserved_stock,
available_stock=body.available_stock, min_stock=body.min_stock,
max_stock=body.max_stock, last_updated_at=body.last_updated_at,
source=body.source, batch_id=body.batch_id, metadata_=body.metadata,
)
upserted = await service.upsert_inventory_record(entity)
return _record_entity_to_response(upserted)
@router.get("/{medication_id}/history", response_model=list[InventoryHistoryResponse])
async def get_inventory_history(
pharmacy_id: UUID,
medication_id: UUID,
offset: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(get_current_user),
):
service = _get_service(session)
history = await service.get_inventory_history(pharmacy_id, medication_id, offset=offset, limit=limit)
return [
InventoryHistoryResponse(
id=h.id, pharmacy_id=h.pharmacy_id, medication_id=h.medication_id,
previous_stock=h.previous_stock, new_stock=h.new_stock, delta=h.delta,
reason=h.reason, source=h.source, created_at=h.created_at,
)
for h in history
]
@@ -0,0 +1,189 @@
from datetime import datetime
from typing import Any
from uuid import UUID
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from src.api.v1.dependencies.auth import get_current_user, require_role
from src.core.exceptions import ConflictException, NotFoundException
from src.domain.entities import MedicationEntity, UserRole
from src.infrastructure.database.repositories import MedicationCatalogRepository, MedicationRepository
from src.infrastructure.database.session import get_db_session
from src.services.auth.security import TokenData
from src.services.medication.medication_service import MedicationService
router = APIRouter(prefix="/medications", tags=["Medications"])
class MedicationCreateRequest(BaseModel):
nregistro: str = Field(..., min_length=1, max_length=50)
name: str = Field(..., min_length=1, max_length=500)
active_ingredient: str | None = Field(None, max_length=500)
dosage: str | None = Field(None, max_length=100)
form: str | None = Field(None, max_length=100)
atc_code: str | None = Field(None, max_length=20)
prescription_required: bool = False
manufacturer: str | None = Field(None, max_length=255)
metadata: dict[str, Any] | None = None
class MedicationUpdateRequest(BaseModel):
name: str | None = Field(None, min_length=1, max_length=500)
active_ingredient: str | None = Field(None, max_length=500)
dosage: str | None = Field(None, max_length=100)
form: str | None = Field(None, max_length=100)
atc_code: str | None = Field(None, max_length=20)
prescription_required: bool | None = None
manufacturer: str | None = Field(None, max_length=255)
metadata: dict[str, Any] | None = None
class MedicationResponse(BaseModel):
id: UUID
nregistro: str
name: str
active_ingredient: str | None
dosage: str | None
form: str | None
atc_code: str | None
prescription_required: bool
manufacturer: str | None
metadata: dict[str, Any] | None
created_at: datetime | None
updated_at: datetime | None
class MedicationListResponse(BaseModel):
items: list[MedicationResponse]
total: int
offset: int
limit: int
def _entity_to_response(e: MedicationEntity) -> MedicationResponse:
return MedicationResponse(
id=e.id, nregistro=e.nregistro, name=e.name,
active_ingredient=e.active_ingredient, dosage=e.dosage,
form=e.form, atc_code=e.atc_code,
prescription_required=e.prescription_required,
manufacturer=e.manufacturer, metadata=e.metadata_,
created_at=e.created_at, updated_at=e.updated_at,
)
def _get_service(session: AsyncSession) -> MedicationService:
return MedicationService(
medication_repo=MedicationRepository(session),
catalog_repo=MedicationCatalogRepository(session),
)
@router.get("", response_model=MedicationListResponse)
async def list_medications(
q: str | None = None,
offset: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(get_current_user),
):
service = _get_service(session)
if q:
entities, total = await service.search_medications(q, offset=offset, limit=limit)
else:
from src.domain.entities import MedicationEntity
all_entities, total = await service.search_medications("", offset=offset, limit=limit)
entities = all_entities
return MedicationListResponse(items=[_entity_to_response(e) for e in entities], total=total, offset=offset, limit=limit)
@router.get("/search", response_model=MedicationListResponse)
async def search_medications(
q: str = Query(..., min_length=1),
offset: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(get_current_user),
):
service = _get_service(session)
entities, total = await service.search_medications(q, offset=offset, limit=limit)
return MedicationListResponse(items=[_entity_to_response(e) for e in entities], total=total, offset=offset, limit=limit)
@router.get("/nregistro/{nregistro}", response_model=MedicationResponse)
async def get_medication_by_nregistro(
nregistro: str,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(get_current_user),
):
service = _get_service(session)
entity = await service.get_by_nregistro(nregistro)
if not entity:
raise NotFoundException("Medication", nregistro)
return _entity_to_response(entity)
@router.get("/{medication_id}", response_model=MedicationResponse)
async def get_medication(
medication_id: UUID,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(get_current_user),
):
service = _get_service(session)
entity = await service.get_medication(medication_id)
if not entity:
raise NotFoundException("Medication", str(medication_id))
return _entity_to_response(entity)
@router.post("", response_model=MedicationResponse, status_code=201)
async def create_medication(
body: MedicationCreateRequest,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
):
service = _get_service(session)
entity = MedicationEntity(
nregistro=body.nregistro, name=body.name,
active_ingredient=body.active_ingredient, dosage=body.dosage,
form=body.form, atc_code=body.atc_code,
prescription_required=body.prescription_required,
manufacturer=body.manufacturer, metadata_=body.metadata,
)
created = await service.create_medication(entity)
return _entity_to_response(created)
@router.put("/{medication_id}", response_model=MedicationResponse)
async def update_medication(
medication_id: UUID,
body: MedicationUpdateRequest,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
):
service = _get_service(session)
entity = await service.get_medication(medication_id)
if not entity:
raise NotFoundException("Medication", str(medication_id))
update_data = body.model_dump(exclude_unset=True)
if "metadata" in update_data:
entity.metadata_ = update_data.pop("metadata")
for key, value in update_data.items():
setattr(entity, key, value)
updated = await service.update_medication(entity)
return _entity_to_response(updated)
@router.delete("/{medication_id}", status_code=204)
async def delete_medication(
medication_id: UUID,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
):
service = _get_service(session)
deleted = await service.delete_medication(medication_id)
if not deleted:
raise NotFoundException("Medication", str(medication_id))
@@ -0,0 +1,185 @@
from datetime import datetime
from typing import Any
from uuid import UUID
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from src.core.exceptions import NotFoundException
from src.domain.entities import PharmacyEntity, PharmacyStatus
from src.domain.interfaces import IPharmacyRepository
from src.infrastructure.database.session import get_db_session
from src.infrastructure.database.repositories import PharmacyRepository
from src.api.v1.dependencies.auth import get_current_user, require_role
from src.domain.entities import UserRole
from src.services.auth.security import TokenData
router = APIRouter(prefix="/pharmacies", tags=["Pharmacies"])
class PharmacyCreateRequest(BaseModel):
code: str = Field(..., min_length=1, max_length=50)
name: str = Field(..., min_length=1, max_length=255)
address: str | None = None
city: str | None = None
province: str | None = None
postal_code: str | None = None
country: str = "ES"
latitude: float | None = None
longitude: float | None = None
phone: str | None = None
email: str | None = None
opening_hours: dict | None = None
erp_type: str | None = None
erp_version: str | None = None
sync_strategy: str = "incremental"
sync_interval_minutes: int = 30
class PharmacyUpdateRequest(BaseModel):
name: str | None = None
address: str | None = None
city: str | None = None
province: str | None = None
postal_code: str | None = None
country: str | None = None
latitude: float | None = None
longitude: float | None = None
phone: str | None = None
email: str | None = None
opening_hours: dict | None = None
status: str | None = None
erp_type: str | None = None
erp_version: str | None = None
sync_strategy: str | None = None
sync_interval_minutes: int | None = None
class PharmacyResponse(BaseModel):
id: UUID
code: str
name: str
address: str | None
city: str | None
province: str | None
postal_code: str | None
country: str
latitude: float | None
longitude: float | None
phone: str | None
email: str | None
opening_hours: dict | None
status: str
erp_type: str | None
erp_version: str | None
connector_id: UUID | None
sync_strategy: str
sync_interval_minutes: int
last_sync_at: datetime | None
last_sync_status: str | None
created_at: datetime | None
updated_at: datetime | None
class PharmacyListResponse(BaseModel):
items: list[PharmacyResponse]
total: int
offset: int
limit: int
def _entity_to_response(e: PharmacyEntity) -> PharmacyResponse:
return PharmacyResponse(
id=e.id, code=e.code, name=e.name, address=e.address, city=e.city,
province=e.province, postal_code=e.postal_code, country=e.country,
latitude=e.latitude, longitude=e.longitude, phone=e.phone, email=e.email,
opening_hours=e.opening_hours, status=e.status, erp_type=e.erp_type,
erp_version=e.erp_version, connector_id=e.connector_id,
sync_strategy=e.sync_strategy, sync_interval_minutes=e.sync_interval_minutes,
last_sync_at=e.last_sync_at, last_sync_status=e.last_sync_status,
created_at=e.created_at, updated_at=e.updated_at,
)
def _get_repo(session: AsyncSession) -> IPharmacyRepository:
return PharmacyRepository(session)
@router.get("", response_model=PharmacyListResponse)
async def list_pharmacies(
status: str | None = None,
erp_type: str | None = None,
offset: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(get_current_user),
):
repo = _get_repo(session)
entities, total = await repo.list_pharmacies(status=status, erp_type=erp_type, offset=offset, limit=limit)
return PharmacyListResponse(items=[_entity_to_response(e) for e in entities], total=total, offset=offset, limit=limit)
@router.get("/{pharmacy_id}", response_model=PharmacyResponse)
async def get_pharmacy(
pharmacy_id: UUID,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(get_current_user),
):
repo = _get_repo(session)
entity = await repo.get_by_id(pharmacy_id)
if not entity:
raise NotFoundException("Pharmacy", str(pharmacy_id))
return _entity_to_response(entity)
@router.post("", response_model=PharmacyResponse, status_code=201)
async def create_pharmacy(
body: PharmacyCreateRequest,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
):
repo = _get_repo(session)
entity = PharmacyEntity(
code=body.code, name=body.name, address=body.address, city=body.city,
province=body.province, postal_code=body.postal_code, country=body.country,
latitude=body.latitude, longitude=body.longitude, phone=body.phone,
email=body.email, opening_hours=body.opening_hours,
status=PharmacyStatus.ACTIVE.value, erp_type=body.erp_type,
erp_version=body.erp_version, sync_strategy=body.sync_strategy,
sync_interval_minutes=body.sync_interval_minutes,
)
created = await repo.create(entity)
return _entity_to_response(created)
@router.put("/{pharmacy_id}", response_model=PharmacyResponse)
async def update_pharmacy(
pharmacy_id: UUID,
body: PharmacyUpdateRequest,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
):
repo = _get_repo(session)
entity = await repo.get_by_id(pharmacy_id)
if not entity:
raise NotFoundException("Pharmacy", str(pharmacy_id))
update_data = body.model_dump(exclude_unset=True)
for key, value in update_data.items():
setattr(entity, key, value)
updated = await repo.update(entity)
return _entity_to_response(updated)
@router.delete("/{pharmacy_id}", status_code=204)
async def delete_pharmacy(
pharmacy_id: UUID,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
):
repo = _get_repo(session)
deleted = await repo.delete(pharmacy_id)
if not deleted:
raise NotFoundException("Pharmacy", str(pharmacy_id))
@@ -0,0 +1,149 @@
from datetime import datetime
from typing import Any
from uuid import UUID
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from src.api.v1.dependencies.auth import get_current_user, require_role
from src.core.exceptions import NotFoundException, ValidationException
from src.domain.entities import ReservationEntity, ReservationStatus, UserRole
from src.infrastructure.database.repositories import InventoryRepository, ReservationRepository
from src.infrastructure.database.session import get_db_session
from src.services.auth.security import TokenData
from src.services.inventory.inventory_service import InventoryService
router = APIRouter(prefix="/pharmacies/{pharmacy_id}/reservations", tags=["Reservations"])
class ReservationCreateRequest(BaseModel):
medication_id: UUID
quantity: int = Field(..., gt=0)
external_reference: str | None = Field(None, max_length=255)
idempotency_key: str | None = Field(None, max_length=255)
ttl_minutes: int = Field(30, ge=1, le=1440)
metadata: dict[str, Any] | None = None
class ReservationResponse(BaseModel):
id: UUID
pharmacy_id: UUID
medication_id: UUID
user_id: UUID | None
external_reference: str | None
quantity: int
status: str
expires_at: datetime | None
confirmed_at: datetime | None
cancelled_at: datetime | None
idempotency_key: str | None
metadata: dict[str, Any] | None
created_at: datetime | None
updated_at: datetime | None
class ReservationListResponse(BaseModel):
items: list[ReservationResponse]
total: int
offset: int
limit: int
def _entity_to_response(e: ReservationEntity) -> ReservationResponse:
return ReservationResponse(
id=e.id, pharmacy_id=e.pharmacy_id, medication_id=e.medication_id,
user_id=e.user_id, external_reference=e.external_reference,
quantity=e.quantity, status=e.status, expires_at=e.expires_at,
confirmed_at=e.confirmed_at, cancelled_at=e.cancelled_at,
idempotency_key=e.idempotency_key, metadata=e.metadata_,
created_at=e.created_at, updated_at=e.updated_at,
)
def _get_service(session: AsyncSession) -> InventoryService:
return InventoryService(
inventory_repo=InventoryRepository(session),
reservation_repo=ReservationRepository(session),
)
@router.get("", response_model=ReservationListResponse)
async def list_reservations(
pharmacy_id: UUID,
status: str | None = None,
offset: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(get_current_user),
):
service = _get_service(session)
entities, total = await service.list_reservations(pharmacy_id, status=status, offset=offset, limit=limit)
return ReservationListResponse(items=[_entity_to_response(e) for e in entities], total=total, offset=offset, limit=limit)
@router.get("/{reservation_id}", response_model=ReservationResponse)
async def get_reservation(
pharmacy_id: UUID,
reservation_id: UUID,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(get_current_user),
):
service = _get_service(session)
entity = await service.get_reservation(reservation_id)
if not entity or entity.pharmacy_id != pharmacy_id:
raise NotFoundException("Reservation", str(reservation_id))
return _entity_to_response(entity)
@router.post("", response_model=ReservationResponse, status_code=201)
async def create_reservation(
pharmacy_id: UUID,
body: ReservationCreateRequest,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(require_role(UserRole.OPERATOR)),
):
service = _get_service(session)
user_id = None
if hasattr(current_user, "user_id"):
user_id = current_user.user_id
elif hasattr(current_user, "sub"):
try:
user_id = UUID(current_user.sub)
except (ValueError, TypeError):
pass
created = await service.create_reservation(
pharmacy_id=pharmacy_id,
medication_id=body.medication_id,
quantity=body.quantity,
user_id=user_id,
external_reference=body.external_reference,
idempotency_key=body.idempotency_key,
ttl_minutes=body.ttl_minutes,
)
return _entity_to_response(created)
@router.post("/{reservation_id}/confirm", response_model=ReservationResponse)
async def confirm_reservation(
pharmacy_id: UUID,
reservation_id: UUID,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(require_role(UserRole.OPERATOR)),
):
service = _get_service(session)
updated = await service.confirm_reservation(reservation_id)
return _entity_to_response(updated)
@router.post("/{reservation_id}/cancel", response_model=ReservationResponse)
async def cancel_reservation(
pharmacy_id: UUID,
reservation_id: UUID,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(require_role(UserRole.OPERATOR)),
):
service = _get_service(session)
updated = await service.cancel_reservation(reservation_id)
return _entity_to_response(updated)
@@ -0,0 +1,199 @@
from datetime import datetime
from typing import Any
from uuid import UUID
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from src.api.v1.dependencies.auth import get_current_user, require_role
from src.core.exceptions import NotFoundException
from src.domain.entities import SyncJobStatus, SyncStrategy, UserRole
from src.infrastructure.database.repositories import SyncJobRepository, SyncLogRepository
from src.infrastructure.database.session import get_db_session
from src.services.auth.security import TokenData
from src.services.synchronization.sync_service import SyncService
router = APIRouter(prefix="/pharmacies/{pharmacy_id}/sync-jobs", tags=["Sync Jobs"])
class SyncJobCreateRequest(BaseModel):
connector_id: UUID
strategy: SyncStrategy = SyncStrategy.INCREMENTAL
class SyncJobResponse(BaseModel):
id: UUID
pharmacy_id: UUID
connector_id: UUID
strategy: str
status: str
started_at: datetime | None
completed_at: datetime | None
items_total: int | None
items_processed: int | None
items_failed: int | None
error_message: str | None
metadata: dict[str, Any] | None
created_at: datetime | None
updated_at: datetime | None
class SyncJobListResponse(BaseModel):
items: list[SyncJobResponse]
total: int
offset: int
limit: int
class SyncLogResponse(BaseModel):
id: UUID
sync_job_id: UUID
level: str
message: str
details: dict[str, Any] | None
created_at: datetime | None
def _entity_to_response(e: Any) -> SyncJobResponse:
return SyncJobResponse(
id=e.id, pharmacy_id=e.pharmacy_id, connector_id=e.connector_id,
strategy=e.strategy, status=e.status, started_at=e.started_at,
completed_at=e.completed_at, items_total=e.items_total,
items_processed=e.items_processed, items_failed=e.items_failed,
error_message=e.error_message, metadata=e.metadata_,
created_at=e.created_at, updated_at=e.updated_at,
)
def _get_service(session: AsyncSession) -> SyncService:
return SyncService(
sync_job_repo=SyncJobRepository(session),
sync_log_repo=SyncLogRepository(session),
)
@router.get("", response_model=SyncJobListResponse)
async def list_sync_jobs(
pharmacy_id: UUID,
status: str | None = None,
offset: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(get_current_user),
):
service = _get_service(session)
entities, total = await service.list_sync_jobs(pharmacy_id, status=status, offset=offset, limit=limit)
return SyncJobListResponse(items=[_entity_to_response(e) for e in entities], total=total, offset=offset, limit=limit)
@router.get("/{job_id}", response_model=SyncJobResponse)
async def get_sync_job(
pharmacy_id: UUID,
job_id: UUID,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(get_current_user),
):
service = _get_service(session)
entity = await service.get_sync_job(job_id)
if not entity or entity.pharmacy_id != pharmacy_id:
raise NotFoundException("SyncJob", str(job_id))
return _entity_to_response(entity)
@router.post("", response_model=SyncJobResponse, status_code=201)
async def create_sync_job(
pharmacy_id: UUID,
body: SyncJobCreateRequest,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(require_role(UserRole.OPERATOR)),
):
service = _get_service(session)
created = await service.create_sync_job(
pharmacy_id=pharmacy_id,
connector_id=body.connector_id,
strategy=body.strategy,
)
return _entity_to_response(created)
@router.post("/{job_id}/trigger", response_model=SyncJobResponse)
async def trigger_sync(
pharmacy_id: UUID,
job_id: UUID,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(require_role(UserRole.OPERATOR)),
):
service = _get_service(session)
updated = await service.trigger_sync(job_id)
return _entity_to_response(updated)
@router.post("/{job_id}/cancel", response_model=SyncJobResponse)
async def cancel_sync(
pharmacy_id: UUID,
job_id: UUID,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(require_role(UserRole.OPERATOR)),
):
service = _get_service(session)
updated = await service.cancel_sync(job_id)
return _entity_to_response(updated)
class SyncCompleteRequest(BaseModel):
items_total: int | None = None
items_processed: int | None = None
items_failed: int | None = None
duration_ms: int = 0
class SyncFailRequest(BaseModel):
error_message: str
@router.post("/{job_id}/complete", response_model=SyncJobResponse)
async def complete_sync(
pharmacy_id: UUID,
job_id: UUID,
body: SyncCompleteRequest,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(require_role(UserRole.OPERATOR)),
):
service = _get_service(session)
updated = await service.complete_sync(job_id, items_total=body.items_total, items_processed=body.items_processed, items_failed=body.items_failed)
return _entity_to_response(updated)
@router.post("/{job_id}/fail", response_model=SyncJobResponse)
async def fail_sync(
pharmacy_id: UUID,
job_id: UUID,
body: SyncFailRequest,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(require_role(UserRole.OPERATOR)),
):
service = _get_service(session)
updated = await service.fail_sync(job_id, error_message=body.error_message)
return _entity_to_response(updated)
@router.get("/{job_id}/logs", response_model=list[SyncLogResponse])
async def get_sync_logs(
pharmacy_id: UUID,
job_id: UUID,
level: str | None = None,
offset: int = Query(0, ge=0),
limit: int = Query(100, ge=1, le=500),
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(get_current_user),
):
service = _get_service(session)
logs = await service.get_sync_logs(job_id, level=level, offset=offset, limit=limit)
return [
SyncLogResponse(
id=l.id, sync_job_id=l.sync_job_id, level=l.level,
message=l.message, details=l.details, created_at=l.created_at,
)
for l in logs
]
@@ -0,0 +1,77 @@
from fastapi import APIRouter, Depends
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import text
from src.infrastructure.cache.redis_cache import redis_cache
from src.infrastructure.database.session import get_db_session
from src.infrastructure.messaging.rabbitmq import rabbitmq_client
from src.api.v1.dependencies.auth import get_current_user
from src.services.auth.security import TokenData
router = APIRouter(prefix="/system", tags=["System"])
class HealthStatus(BaseModel):
status: str
version: str
services: dict[str, str]
class ReadinessStatus(BaseModel):
status: str
checks: dict[str, bool]
@router.get("/health", response_model=HealthStatus)
async def health_check():
return HealthStatus(
status="healthy",
version="0.1.0",
services={
"api": "healthy",
},
)
@router.get("/ready", response_model=ReadinessStatus)
async def readiness_check(db: AsyncSession = Depends(get_db_session)):
checks: dict[str, bool] = {}
try:
# Check database connection
await db.execute(text("SELECT 1"))
db_ok = True
except Exception:
db_ok = False
checks["database"] = db_ok
redis_ok = await redis_cache.health_check()
checks["redis"] = redis_ok
mq_ok = await rabbitmq_client.health_check()
checks["rabbitmq"] = mq_ok
overall = all(checks.values())
return ReadinessStatus(
status="ready" if overall else "degraded",
checks=checks,
)
@router.get("/status")
async def system_status(current_user: TokenData = Depends(get_current_user)):
return {
"platform": "Pharmacy Integration Platform",
"version": "0.1.0",
"environment": "production",
"services": {
"auth": "active",
"pharmacy": "active",
"medication": "active",
"connector": "active",
"synchronization": "active",
"audit": "active",
"notification": "active",
},
}
@@ -0,0 +1,70 @@
from datetime import datetime
from uuid import UUID
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession
from src.core.exceptions import NotFoundException
from src.domain.entities import TechnicalUserEntity, UserRole
from src.domain.interfaces import ITechnicalUserRepository
from src.infrastructure.database.repositories import TechnicalUserRepository
from src.infrastructure.database.session import get_db_session
from src.api.v1.dependencies.auth import get_current_user, require_role
from src.services.auth.security import TokenData
router = APIRouter(prefix="/users", tags=["Technical Users"])
class UserResponse(BaseModel):
id: UUID
username: str
email: str | None
full_name: str | None
role: str
is_active: bool
scopes: list[str]
last_login_at: datetime | None
created_at: datetime | None
class UserListResponse(BaseModel):
items: list[UserResponse]
total: int
offset: int
limit: int
def _entity_to_response(e: TechnicalUserEntity) -> UserResponse:
return UserResponse(
id=e.id, username=e.username, email=e.email, full_name=e.full_name,
role=e.role, is_active=e.is_active, scopes=e.scopes,
last_login_at=e.last_login_at, created_at=e.created_at,
)
@router.get("", response_model=UserListResponse)
async def list_users(
role: str | None = None,
is_active: bool | None = None,
offset: int = Query(0, ge=0),
limit: int = Query(50, ge=1, le=200),
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
):
repo = TechnicalUserRepository(session)
entities, total = await repo.list_users(role=role, is_active=is_active, offset=offset, limit=limit)
return UserListResponse(items=[_entity_to_response(e) for e in entities], total=total, offset=offset, limit=limit)
@router.get("/{user_id}", response_model=UserResponse)
async def get_user(
user_id: UUID,
session: AsyncSession = Depends(get_db_session),
current_user: TokenData = Depends(require_role(UserRole.ADMIN)),
):
repo = TechnicalUserRepository(session)
entity = await repo.get_by_id(user_id)
if not entity:
raise NotFoundException("User", str(user_id))
return _entity_to_response(entity)
+31
View File
@@ -0,0 +1,31 @@
from fastapi import APIRouter
from src.api.v1.endpoints.auth import router as auth_router
from src.api.v1.endpoints.pharmacies import router as pharmacies_router
from src.api.v1.endpoints.users import router as users_router
from src.api.v1.endpoints.api_clients import router as api_clients_router
from src.api.v1.endpoints.audit import router as audit_router
from src.api.v1.endpoints.system import router as system_router
from src.api.v1.endpoints.connectors import router as connectors_router
from src.api.v1.endpoints.erp_systems import router as erp_systems_router
from src.api.v1.endpoints.medications import router as medications_router
from src.api.v1.endpoints.catalog import router as catalog_router
from src.api.v1.endpoints.inventory import router as inventory_router
from src.api.v1.endpoints.reservations import router as reservations_router
from src.api.v1.endpoints.sync_jobs import router as sync_jobs_router
v1_router = APIRouter(prefix="/api/v1")
v1_router.include_router(auth_router)
v1_router.include_router(pharmacies_router)
v1_router.include_router(users_router)
v1_router.include_router(api_clients_router)
v1_router.include_router(audit_router)
v1_router.include_router(system_router)
v1_router.include_router(connectors_router)
v1_router.include_router(erp_systems_router)
v1_router.include_router(medications_router)
v1_router.include_router(catalog_router)
v1_router.include_router(inventory_router)
v1_router.include_router(reservations_router)
v1_router.include_router(sync_jobs_router)
+117
View File
@@ -0,0 +1,117 @@
"""CLI entrypoints for the PIP platform."""
import asyncio
import sys
import click
from src.core.logging import configure_logging, get_logger
from src.infrastructure.cache.redis_cache import redis_cache
from src.infrastructure.config.settings import settings
from src.infrastructure.database.session import async_session_factory, engine
from src.infrastructure.messaging.rabbitmq import rabbitmq_client
from src.services.auth.auth_service import AuthService
from src.domain.entities import UserRole
@click.group()
def cli():
"""PIP platform CLI."""
configure_logging()
@cli.command()
def run():
"""Start the API server."""
import uvicorn
uvicorn.run(
"src.main:app",
host="0.0.0.0",
port=8000,
reload=True if settings.DEBUG else False,
log_level=settings.LOG_LEVEL.lower(),
)
@cli.command()
def migrate_upgrade():
"""Apply database migrations."""
from alembic.config import Config
from alembic import command
cfg = Config("alembic.ini")
command.upgrade(cfg, "head")
click.echo("Migrations applied.")
@cli.command()
def migrate_downgrade():
"""Roll back the last database migration."""
from alembic.config import Config
from alembic import command
cfg = Config("alembic.ini")
command.downgrade(cfg, "-1")
click.echo("Rollback complete.")
@cli.command()
def migrate_revision():
"""Create a new database revision."""
from alembic.config import Config
from alembic import command
message = click.prompt("Revision message")
cfg = Config("alembic.ini")
command.revision(cfg, autogenerate=True, message=message)
click.echo("Revision created.")
@cli.command()
@click.option("--username", required=True)
@click.option("--password", required=True)
@click.option("--email", default=None)
@click.option("--role", default="admin", type=click.Choice([r.value for r in UserRole]))
def create_user(username: str, password: str, email: str | None, role: str):
"""Create a technical user."""
asyncio.run(_create_user(username, password, email, role))
async def _create_user(username: str, password: str, email: str | None, role: str):
async with async_session_factory() as session:
service = AuthService(session)
user = await service.register(
username=username,
password=password,
email=email,
role=UserRole(role),
)
click.echo(f"Created user '{user.username}' with role '{user.role.value}'.")
@cli.command()
def health():
"""Check platform health across services."""
asyncio.run(_health())
async def _health():
logger = get_logger("cli.health")
async with engine.begin() as conn:
await conn.run_sync(lambda s: None)
click.echo("Database: OK")
await redis_cache.connect()
ok = await redis_cache.health_check()
click.echo(f"Redis: {'OK' if ok else 'FAIL'}")
await redis_cache.disconnect()
await rabbitmq_client.connect()
ok = await rabbitmq_client.health_check()
click.echo(f"RabbitMQ: {'OK' if ok else 'FAIL'}")
await rabbitmq_client.disconnect()
await engine.dispose()
if __name__ == "__main__":
cli()

Some files were not shown because too many files have changed in this diff Show More