Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8615e213de | |||
| c5df0ec0b2 | |||
| d935db4b3e | |||
| 1255bda9c7 | |||
| 1c31362e0b |
@@ -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
@@ -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.
|
* Used by the backend admin import; OSM does not require n8n.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -83,7 +83,7 @@ export async function fetchPharmaciesFromOpenDataUrl(openDataUrl) {
|
|||||||
const res = await fetch(u, {
|
const res = await fetch(u, {
|
||||||
headers: {
|
headers: {
|
||||||
Accept: 'application/json',
|
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();
|
const text = await res.text();
|
||||||
|
|||||||
@@ -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 }.
|
* shape: { mon: ["09:00", "21:00"], ..., sun: null }.
|
||||||
*
|
*
|
||||||
* Supports the patterns commonly seen on amenity=pharmacy nodes:
|
* Supports the patterns commonly seen on amenity=pharmacy nodes:
|
||||||
|
|||||||
+1
-1
@@ -35,7 +35,7 @@ async function fetchOverpass(endpoint, body) {
|
|||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
'Content-Type': 'application/x-www-form-urlencoded',
|
||||||
Accept: 'application/json',
|
Accept: 'application/json',
|
||||||
'User-Agent': 'FarmaFinder/1.0 (OSM pharmacy import; local admin)',
|
'User-Agent': 'FarmaClic/1.0 (OSM pharmacy import; local admin)',
|
||||||
},
|
},
|
||||||
body,
|
body,
|
||||||
});
|
});
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "farma-finder-api-sources",
|
"name": "farma-clic-api-sources",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module"
|
"type": "module"
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+3
@@ -0,0 +1,3 @@
|
|||||||
|
# Default ignored files
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
Generated
+6
@@ -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>
|
||||||
Generated
+6
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="CompilerConfiguration">
|
||||||
|
<bytecodeTargetLevel target="21" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
+11
@@ -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>
|
||||||
Generated
+13
@@ -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>
|
||||||
Generated
+10
@@ -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>
|
||||||
Generated
+10
@@ -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>
|
||||||
Generated
+13
@@ -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>
|
||||||
Generated
+17
@@ -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>
|
||||||
Generated
+6
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="StudioBotProjectSettings">
|
||||||
|
<option name="shareContext" value="OptedIn" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
@@ -7,10 +7,10 @@ if (keystorePropertiesFile.exists()) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace "net.hacecalor.farmafinder"
|
namespace "net.hacecalor.farmaclic"
|
||||||
compileSdk rootProject.ext.compileSdkVersion
|
compileSdk rootProject.ext.compileSdkVersion
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId "net.hacecalor.farmafinder"
|
applicationId "net.hacecalor.farmaclic"
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 1
|
versionCode 1
|
||||||
@@ -36,7 +36,7 @@ android {
|
|||||||
release {
|
release {
|
||||||
minifyEnabled true
|
minifyEnabled true
|
||||||
shrinkResources true
|
shrinkResources true
|
||||||
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
|
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
|
||||||
if (keystorePropertiesFile.exists()) {
|
if (keystorePropertiesFile.exists()) {
|
||||||
signingConfig signingConfigs.release
|
signingConfig signingConfigs.release
|
||||||
}
|
}
|
||||||
@@ -44,14 +44,8 @@ android {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
repositories {
|
|
||||||
flatDir{
|
|
||||||
dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
implementation fileTree(include: ['*.jar'], dir: 'libs')
|
implementation fileTree(include: ['*.jar', '*.aar'], dir: 'libs')
|
||||||
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
|
implementation "androidx.appcompat:appcompat:$androidxAppCompatVersion"
|
||||||
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
|
implementation "androidx.coordinatorlayout:coordinatorlayout:$androidxCoordinatorLayoutVersion"
|
||||||
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
|
implementation "androidx.core:core-splashscreen:$coreSplashScreenVersion"
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package net.hacecalor.farmafinder;
|
package net.hacecalor.farmaclic;
|
||||||
|
|
||||||
import com.getcapacitor.BridgeActivity;
|
import com.getcapacitor.BridgeActivity;
|
||||||
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<?xml version='1.0' encoding='utf-8'?>
|
<?xml version='1.0' encoding='utf-8'?>
|
||||||
<resources>
|
<resources>
|
||||||
<string name="app_name">FarmaFinder</string>
|
<string name="app_name">FarmaClic</string>
|
||||||
<string name="title_activity_main">FarmaFinder</string>
|
<string name="title_activity_main">FarmaClic</string>
|
||||||
<string name="package_name">net.hacecalor.farmafinder</string>
|
<string name="package_name">net.hacecalor.farmaclic</string>
|
||||||
<string name="custom_url_scheme">net.hacecalor.farmafinder</string>
|
<string name="custom_url_scheme">net.hacecalor.farmaclic</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ buildscript {
|
|||||||
mavenCentral()
|
mavenCentral()
|
||||||
}
|
}
|
||||||
dependencies {
|
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'
|
classpath 'com.google.gms:google-services:4.4.4'
|
||||||
|
|
||||||
// NOTE: Do not place your application dependencies here; they belong
|
// NOTE: Do not place your application dependencies here; they belong
|
||||||
|
|||||||
@@ -20,3 +20,8 @@ org.gradle.jvmargs=-Xmx1536m
|
|||||||
# Android operating system, and which are packaged with your app's APK
|
# Android operating system, and which are packaged with your app's APK
|
||||||
# https://developer.android.com/topic/libraries/support-library/androidx-rn
|
# https://developer.android.com/topic/libraries/support-library/androidx-rn
|
||||||
android.useAndroidX=true
|
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
|
||||||
BIN
Binary file not shown.
+3
-2
@@ -1,8 +1,9 @@
|
|||||||
#Fri Jun 26 12:32:52 CEST 2026
|
|
||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
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
|
networkTimeout=10000
|
||||||
|
retries=0
|
||||||
|
retryBackOffMs=500
|
||||||
validateDistributionUrl=true
|
validateDistributionUrl=true
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
zipStorePath=wrapper/dists
|
zipStorePath=wrapper/dists
|
||||||
|
|||||||
Vendored
+16
-16
@@ -1,7 +1,7 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
|
|
||||||
#
|
#
|
||||||
# Copyright © 2015-2021 the original authors.
|
# Copyright © 2015 the original authors.
|
||||||
#
|
#
|
||||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
# you may not use this file except in compliance with 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
|
# See the License for the specific language governing permissions and
|
||||||
# limitations under the License.
|
# 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:
|
# Important for running:
|
||||||
#
|
#
|
||||||
@@ -27,7 +29,7 @@
|
|||||||
# bash, then to run this script, type that shell name before the whole
|
# bash, then to run this script, type that shell name before the whole
|
||||||
# command line, like:
|
# command line, like:
|
||||||
#
|
#
|
||||||
# ksh Gradle
|
# ksh gradlew
|
||||||
#
|
#
|
||||||
# Busybox and similar reduced shells will NOT work, because this script
|
# Busybox and similar reduced shells will NOT work, because this script
|
||||||
# requires all of these POSIX shell features:
|
# requires all of these POSIX shell features:
|
||||||
@@ -55,7 +57,7 @@
|
|||||||
# Darwin, MinGW, and NonStop.
|
# Darwin, MinGW, and NonStop.
|
||||||
#
|
#
|
||||||
# (3) This script is generated from the Groovy template
|
# (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.
|
# within the Gradle project.
|
||||||
#
|
#
|
||||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||||
@@ -83,7 +85,8 @@ done
|
|||||||
# This is normally unused
|
# This is normally unused
|
||||||
# shellcheck disable=SC2034
|
# shellcheck disable=SC2034
|
||||||
APP_BASE_NAME=${0##*/}
|
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.
|
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||||
MAX_FD=maximum
|
MAX_FD=maximum
|
||||||
@@ -111,7 +114,6 @@ case "$( uname )" in #(
|
|||||||
NONSTOP* ) nonstop=true ;;
|
NONSTOP* ) nonstop=true ;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
|
||||||
|
|
||||||
|
|
||||||
# Determine the Java command to use to start the JVM.
|
# Determine the Java command to use to start the JVM.
|
||||||
@@ -144,7 +146,7 @@ if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
|||||||
case $MAX_FD in #(
|
case $MAX_FD in #(
|
||||||
max*)
|
max*)
|
||||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
# 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 ) ||
|
MAX_FD=$( ulimit -H -n ) ||
|
||||||
warn "Could not query maximum file descriptor limit"
|
warn "Could not query maximum file descriptor limit"
|
||||||
esac
|
esac
|
||||||
@@ -152,7 +154,7 @@ if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
|||||||
'' | soft) :;; #(
|
'' | soft) :;; #(
|
||||||
*)
|
*)
|
||||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
# 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" ||
|
ulimit -n "$MAX_FD" ||
|
||||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||||
esac
|
esac
|
||||||
@@ -169,7 +171,6 @@ fi
|
|||||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||||
if "$cygwin" || "$msys" ; then
|
if "$cygwin" || "$msys" ; then
|
||||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||||
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
|
||||||
|
|
||||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
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.
|
# 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"'
|
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||||
|
|
||||||
# Collect all arguments for the java command;
|
# Collect all arguments for the java command:
|
||||||
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
|
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
|
||||||
# shell script including quotes and variable substitutions, so put them in
|
# and any embedded shellness will be escaped.
|
||||||
# double quotes to make sure that they get re-expanded; and
|
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
|
||||||
# * put everything else in single quotes, so that it's not re-expanded.
|
# treated as '${Hostname}' itself on the command line.
|
||||||
|
|
||||||
set -- \
|
set -- \
|
||||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||||
-classpath "$CLASSPATH" \
|
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
|
||||||
org.gradle.wrapper.GradleWrapperMain \
|
|
||||||
"$@"
|
"$@"
|
||||||
|
|
||||||
# Stop when "xargs" is not available.
|
# Stop when "xargs" is not available.
|
||||||
|
|||||||
Vendored
+24
-34
@@ -13,16 +13,18 @@
|
|||||||
@rem See the License for the specific language governing permissions and
|
@rem See the License for the specific language governing permissions and
|
||||||
@rem limitations under the License.
|
@rem limitations under the License.
|
||||||
@rem
|
@rem
|
||||||
|
@rem SPDX-License-Identifier: Apache-2.0
|
||||||
|
@rem
|
||||||
|
|
||||||
@if "%DEBUG%"=="" @echo off
|
@if "%DEBUG%"=="" @echo off
|
||||||
@rem ##########################################################################
|
@rem ##########################################################################
|
||||||
@rem
|
@rem
|
||||||
@rem Gradle startup script for Windows
|
@rem gradlew startup script for Windows
|
||||||
@rem
|
@rem
|
||||||
@rem ##########################################################################
|
@rem ##########################################################################
|
||||||
|
|
||||||
@rem Set local scope for the variables with windows NT shell
|
@rem Set local scope for the variables, and ensure extensions are enabled
|
||||||
if "%OS%"=="Windows_NT" setlocal
|
setlocal EnableExtensions
|
||||||
|
|
||||||
set DIRNAME=%~dp0
|
set DIRNAME=%~dp0
|
||||||
if "%DIRNAME%"=="" set DIRNAME=.
|
if "%DIRNAME%"=="" set DIRNAME=.
|
||||||
@@ -43,13 +45,13 @@ set JAVA_EXE=java.exe
|
|||||||
%JAVA_EXE% -version >NUL 2>&1
|
%JAVA_EXE% -version >NUL 2>&1
|
||||||
if %ERRORLEVEL% equ 0 goto execute
|
if %ERRORLEVEL% equ 0 goto execute
|
||||||
|
|
||||||
echo.
|
echo. 1>&2
|
||||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||||
echo.
|
echo. 1>&2
|
||||||
echo Please set the JAVA_HOME variable in your environment to match the
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
echo location of your Java installation.
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
goto fail
|
"%COMSPEC%" /c exit 1
|
||||||
|
|
||||||
:findJavaFromJavaHome
|
:findJavaFromJavaHome
|
||||||
set JAVA_HOME=%JAVA_HOME:"=%
|
set JAVA_HOME=%JAVA_HOME:"=%
|
||||||
@@ -57,36 +59,24 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
|||||||
|
|
||||||
if exist "%JAVA_EXE%" goto execute
|
if exist "%JAVA_EXE%" goto execute
|
||||||
|
|
||||||
echo.
|
echo. 1>&2
|
||||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
|
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||||
echo.
|
echo. 1>&2
|
||||||
echo Please set the JAVA_HOME variable in your environment to match the
|
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||||
echo location of your Java installation.
|
echo location of your Java installation. 1>&2
|
||||||
|
|
||||||
goto fail
|
"%COMSPEC%" /c exit 1
|
||||||
|
|
||||||
:execute
|
:execute
|
||||||
@rem Setup the command line
|
@rem Setup the command line
|
||||||
|
|
||||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
|
||||||
|
|
||||||
|
|
||||||
@rem Execute Gradle
|
@rem Execute gradlew
|
||||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
@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
|
:exitWithErrorLevel
|
||||||
@rem End local scope for the variables with windows NT shell
|
@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts
|
||||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
"%COMSPEC%" /c exit %ERRORLEVEL%
|
||||||
|
|
||||||
: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
|
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
plugins {
|
||||||
|
id 'org.gradle.toolchains.foojay-resolver-convention' version '1.0.0'
|
||||||
|
}
|
||||||
include ':app'
|
include ':app'
|
||||||
include ':capacitor-cordova-android-plugins'
|
include ':capacitor-cordova-android-plugins'
|
||||||
project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/')
|
project(':capacitor-cordova-android-plugins').projectDir = new File('./capacitor-cordova-android-plugins/')
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ REDIS_PORT=6379
|
|||||||
REDIS_PASSWORD=
|
REDIS_PASSWORD=
|
||||||
|
|
||||||
# PostgreSQL for user accounts (leave unset to fallback to SQLite — dev/test only)
|
# 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
|
PG_PASSWORD=change-me
|
||||||
|
|
||||||
# Web Push (VAPID). Generate with:
|
# Web Push (VAPID). Generate with:
|
||||||
|
|||||||
@@ -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.
|
* Default URL: FARMACIAS_WEBHOOK_URL env or the project webhook.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "farma-finder-backend",
|
"name": "farma-clic-backend",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "Backend API for FarmaFinder",
|
"description": "Backend API for FarmaClic",
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
|
|
||||||
echo "🔄 FarmaFinder - Quick Database Reset"
|
echo "🔄 FarmaClic - Quick Database Reset"
|
||||||
echo "===================================="
|
echo "===================================="
|
||||||
echo ""
|
echo ""
|
||||||
echo "Este script eliminará la base de datos actual y creará una nueva."
|
echo "Este script eliminará la base de datos actual y creará una nueva."
|
||||||
|
|||||||
+3
-3
@@ -30,7 +30,7 @@ const PORT = process.env.PORT || 3001;
|
|||||||
// span_id to every log line so they can be correlated in Grafana.
|
// span_id to every log line so they can be correlated in Grafana.
|
||||||
const logger = pino({
|
const logger = pino({
|
||||||
level: process.env.LOG_LEVEL || 'info',
|
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
|
// Trust the reverse proxy in front of us (Docker/Traefik/nginx/Cloudflare) so
|
||||||
@@ -61,7 +61,7 @@ if (PG_URL) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sessionConfig = {
|
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,
|
resave: false,
|
||||||
saveUninitialized: false,
|
saveUninitialized: false,
|
||||||
cookie: {
|
cookie: {
|
||||||
@@ -703,7 +703,7 @@ async function nominatimSearchFirstHit(originalQuery) {
|
|||||||
Accept: 'application/json',
|
Accept: 'application/json',
|
||||||
'Accept-Language': 'es,en',
|
'Accept-Language': 'es,en',
|
||||||
'User-Agent':
|
'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();
|
const text = await nomRes.text();
|
||||||
|
|||||||
@@ -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).
|
// Started as a side-effect import from server.js (ESM).
|
||||||
//
|
//
|
||||||
// Env vars (set by docker-compose):
|
// 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)
|
// OTEL_EXPORTER_OTLP_ENDPOINT — OTLP gRPC endpoint (e.g. http://alloy:4317)
|
||||||
//
|
//
|
||||||
// Exports traces to the shared Grafana Alloy collector, where they are
|
// 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 { ATTR_SERVICE_NAME, ATTR_SERVICE_NAMESPACE } from '@opentelemetry/semantic-conventions';
|
||||||
import { PinoInstrumentation } from '@opentelemetry/instrumentation-pino';
|
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 otlpEndpoint = process.env.OTEL_EXPORTER_OTLP_ENDPOINT || 'http://localhost:4317';
|
||||||
|
|
||||||
const resource = typeof resources.resourceFromAttributes === 'function'
|
const resource = typeof resources.resourceFromAttributes === 'function'
|
||||||
? resources.resourceFromAttributes({
|
? resources.resourceFromAttributes({
|
||||||
[ATTR_SERVICE_NAME]: serviceName,
|
[ATTR_SERVICE_NAME]: serviceName,
|
||||||
[ATTR_SERVICE_NAMESPACE]: 'farmafinder',
|
[ATTR_SERVICE_NAMESPACE]: 'farmaclic',
|
||||||
})
|
})
|
||||||
: new resources.Resource({
|
: new resources.Resource({
|
||||||
[ATTR_SERVICE_NAME]: serviceName,
|
[ATTR_SERVICE_NAME]: serviceName,
|
||||||
[ATTR_SERVICE_NAMESPACE]: 'farmafinder',
|
[ATTR_SERVICE_NAMESPACE]: 'farmaclic',
|
||||||
});
|
});
|
||||||
|
|
||||||
const sdk = new NodeSDK({
|
const sdk = new NodeSDK({
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"appId": "net.hacecalor.farmafinder",
|
"appId": "net.hacecalor.farmaclic",
|
||||||
"appName": "FarmaFinder",
|
"appName": "FarmaClic",
|
||||||
"webDir": "frontend/dist",
|
"webDir": "frontend/dist",
|
||||||
"server": {
|
"server": {
|
||||||
"androidScheme": "https"
|
"androidScheme": "https"
|
||||||
|
|||||||
+10
-10
@@ -7,14 +7,14 @@ services:
|
|||||||
image: postgres:16-alpine
|
image: postgres:16-alpine
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: farmafinder
|
POSTGRES_DB: farmaclic
|
||||||
POSTGRES_USER: farmafinder
|
POSTGRES_USER: farmaclic
|
||||||
POSTGRES_PASSWORD: ${PG_PASSWORD:-change-me-in-production}
|
POSTGRES_PASSWORD: ${PG_PASSWORD:-change-me-in-production}
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql/data
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
image: git.hacecalor.net/ichitux/farmafinder-backend:latest
|
image: git.hacecalor.net/ichitux/farmaclic-backend:latest
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: backend/Dockerfile
|
dockerfile: backend/Dockerfile
|
||||||
@@ -25,19 +25,19 @@ services:
|
|||||||
PORT: "3001"
|
PORT: "3001"
|
||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
SESSION_SECRET: ${SESSION_SECRET:-change-me-in-production}
|
SESSION_SECRET: ${SESSION_SECRET:-change-me-in-production}
|
||||||
CORS_ORIGIN: http://localhost:3000
|
CORS_ORIGIN: http://localhost:4000
|
||||||
REDIS_HOST: redis
|
REDIS_HOST: redis
|
||||||
REDIS_PORT: "6379"
|
REDIS_PORT: "6379"
|
||||||
REDIS_PASSWORD: ${REDIS_PASSWORD:-}
|
REDIS_PASSWORD: ${REDIS_PASSWORD:-}
|
||||||
DATABASE_PATH: /app/data/database.sqlite
|
DATABASE_PATH: /app/data/database.sqlite
|
||||||
FARMACIAS_WEBHOOK_URL: ${FARMACIAS_WEBHOOK_URL:-}
|
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
|
# 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_EXPORTER_OTLP_ENDPOINT: http://host.docker.internal:4317
|
||||||
OTEL_TRACES_EXPORTER: otlp
|
OTEL_TRACES_EXPORTER: otlp
|
||||||
OTEL_LOGS_EXPORTER: otlp
|
OTEL_LOGS_EXPORTER: otlp
|
||||||
OTEL_RESOURCE_ATTRIBUTES: service.namespace=farmafinder
|
OTEL_RESOURCE_ATTRIBUTES: service.namespace=farmaclic
|
||||||
volumes:
|
volumes:
|
||||||
- backend_data:/app/data
|
- backend_data:/app/data
|
||||||
depends_on:
|
depends_on:
|
||||||
@@ -45,17 +45,17 @@ services:
|
|||||||
- postgres
|
- postgres
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
image: git.hacecalor.net/ichitux/farmafinder-frontend:latest
|
image: git.hacecalor.net/ichitux/farmaclic-frontend:latest
|
||||||
build:
|
build:
|
||||||
context: ./frontend
|
context: ./frontend
|
||||||
args:
|
args:
|
||||||
VITE_FARO_ENDPOINT: ${VITE_FARO_ENDPOINT:-http://localhost:4318}
|
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_ENV: ${VITE_FARO_ENV:-production}
|
||||||
VITE_FARO_APP_VERSION: ${VITE_FARO_APP_VERSION:-1.0.0}
|
VITE_FARO_APP_VERSION: ${VITE_FARO_APP_VERSION:-1.0.0}
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
ports:
|
ports:
|
||||||
- "3000:80"
|
- "4000:80"
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
VITE_FARO_ENDPOINT=http://localhost:4318
|
VITE_FARO_ENDPOINT=http://localhost:4318
|
||||||
|
|
||||||
# App name shown in Grafana Explore / Faro data source.
|
# 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).
|
# Environment label (production | staging | development).
|
||||||
VITE_FARO_ENV=production
|
VITE_FARO_ENV=production
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
FROM node:20-alpine AS build
|
FROM node:20-alpine AS build
|
||||||
ARG VITE_FARO_ENDPOINT
|
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_ENV=production
|
||||||
ARG VITE_FARO_APP_VERSION=1.0.0
|
ARG VITE_FARO_APP_VERSION=1.0.0
|
||||||
ENV VITE_FARO_ENDPOINT=$VITE_FARO_ENDPOINT
|
ENV VITE_FARO_ENDPOINT=$VITE_FARO_ENDPOINT
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
<meta name="theme-color" content="#00450d" />
|
<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.png" type="image/png" />
|
||||||
<link rel="icon" href="/favicon.ico" sizes="32x32" />
|
<link rel="icon" href="/favicon.ico" sizes="32x32" />
|
||||||
<link rel="apple-touch-icon" href="/logo.png" />
|
<link rel="apple-touch-icon" href="/logo.png" />
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "farma-finder-frontend",
|
"name": "farma-clic-frontend",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ function App() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let activeView;
|
let activeView;
|
||||||
let topBarTitle = 'FarmaFinder';
|
let topBarTitle = 'FarmaClic';
|
||||||
let showBack = false;
|
let showBack = false;
|
||||||
|
|
||||||
switch (screen) {
|
switch (screen) {
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ function LoginModal({ onLogin, onClose, initialMode = 'login' }) {
|
|||||||
const u = username.trim();
|
const u = username.trim();
|
||||||
if (!u || !password) return;
|
if (!u || !password) return;
|
||||||
if (mode === 'register' && password.length < 8) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -40,12 +40,12 @@ function LoginModal({ onLogin, onClose, initialMode = 'login' }) {
|
|||||||
});
|
});
|
||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
if (!res.ok) {
|
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 {
|
} else {
|
||||||
onLogin(data.user);
|
onLogin(data.user);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
setError('Network error — try again');
|
setError('Error de red — inténtalo de nuevo');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -71,7 +71,7 @@ function LoginModal({ onLogin, onClose, initialMode = 'login' }) {
|
|||||||
onClick={() => setMode('login')}
|
onClick={() => setMode('login')}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
Sign in
|
Iniciar sesión
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -81,20 +81,20 @@ function LoginModal({ onLogin, onClose, initialMode = 'login' }) {
|
|||||||
onClick={() => setMode('register')}
|
onClick={() => setMode('register')}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
Create account
|
Crear cuenta
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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">
|
<p className="modal-sub">
|
||||||
{isRegister
|
{isRegister
|
||||||
? 'Save your address and get notified when medicines arrive.'
|
? 'Guarda tu dirección y recibe notificaciones cuando lleguen medicamentos.'
|
||||||
: 'Sign in to manage your profile and notifications.'}
|
: 'Inicia sesión para gestionar tu perfil y notificaciones.'}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} noValidate>
|
<form onSubmit={handleSubmit} noValidate>
|
||||||
<div className="modal-field">
|
<div className="modal-field">
|
||||||
<label htmlFor="modal-username">Username</label>
|
<label htmlFor="modal-username">Usuario</label>
|
||||||
<input
|
<input
|
||||||
id="modal-username"
|
id="modal-username"
|
||||||
type="text"
|
type="text"
|
||||||
@@ -107,11 +107,11 @@ function LoginModal({ onLogin, onClose, initialMode = 'login' }) {
|
|||||||
maxLength={isRegister ? 32 : undefined}
|
maxLength={isRegister ? 32 : undefined}
|
||||||
/>
|
/>
|
||||||
{isRegister && (
|
{isRegister && (
|
||||||
<p className="modal-hint">3–32 characters: letters, digits, or underscore.</p>
|
<p className="modal-hint">3–32 caracteres: letras, dígitos o guión bajo.</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="modal-field">
|
<div className="modal-field">
|
||||||
<label htmlFor="modal-password">Password</label>
|
<label htmlFor="modal-password">Contraseña</label>
|
||||||
<input
|
<input
|
||||||
id="modal-password"
|
id="modal-password"
|
||||||
type="password"
|
type="password"
|
||||||
@@ -122,7 +122,7 @@ function LoginModal({ onLogin, onClose, initialMode = 'login' }) {
|
|||||||
minLength={isRegister ? 8 : undefined}
|
minLength={isRegister ? 8 : undefined}
|
||||||
/>
|
/>
|
||||||
{isRegister && (
|
{isRegister && (
|
||||||
<p className="modal-hint">At least 8 characters.</p>
|
<p className="modal-hint">Al menos 8 caracteres.</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{error && <p className="modal-error">{error}</p>}
|
{error && <p className="modal-error">{error}</p>}
|
||||||
@@ -133,7 +133,7 @@ function LoginModal({ onLogin, onClose, initialMode = 'login' }) {
|
|||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
>
|
>
|
||||||
Cancel
|
Cancelar
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
@@ -141,8 +141,8 @@ function LoginModal({ onLogin, onClose, initialMode = 'login' }) {
|
|||||||
disabled={loading || !username.trim() || !password}
|
disabled={loading || !username.trim() || !password}
|
||||||
>
|
>
|
||||||
{loading
|
{loading
|
||||||
? (isRegister ? 'Creating…' : 'Signing in…')
|
? (isRegister ? 'Creando…' : 'Iniciando sesión…')
|
||||||
: (isRegister ? 'Create account' : 'Sign in')}
|
: (isRegister ? 'Crear cuenta' : 'Iniciar sesión')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ function MedicineResults({ medicines, onSelect, query, currentUser, onLoginReque
|
|||||||
if (medicines.length === 0 && query.length >= 2) {
|
if (medicines.length === 0 && query.length >= 2) {
|
||||||
return (
|
return (
|
||||||
<div className="no-results">
|
<div className="no-results">
|
||||||
<p>No medicines found matching "{query}"</p>
|
<p>No se encontraron medicamentos para "{query}"</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -49,7 +49,7 @@ function MedicineCard({ medicine, onSelect, currentUser, onLoginRequest }) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!supported) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
if (busy) return;
|
if (busy) return;
|
||||||
@@ -65,7 +65,7 @@ function MedicineCard({ medicine, onSelect, currentUser, onLoginRequest }) {
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[notify] toggle failed:', 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 {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
@@ -83,29 +83,29 @@ function MedicineCard({ medicine, onSelect, currentUser, onLoginRequest }) {
|
|||||||
aria-pressed={subscribed && !!currentUser}
|
aria-pressed={subscribed && !!currentUser}
|
||||||
aria-label={
|
aria-label={
|
||||||
!currentUser
|
!currentUser
|
||||||
? 'Login to enable notifications'
|
? 'Inicia sesión para activar notificaciones'
|
||||||
: subscribed
|
: subscribed
|
||||||
? 'Stop notifications for this medicine'
|
? 'Desactivar notificaciones para este medicamento'
|
||||||
: 'Notify me when this medicine becomes available'
|
: 'Notificarme cuando esté disponible'
|
||||||
}
|
}
|
||||||
title={
|
title={
|
||||||
!currentUser
|
!currentUser
|
||||||
? 'Login to enable notifications'
|
? 'Inicia sesión para activar notificaciones'
|
||||||
: subscribed
|
: subscribed
|
||||||
? 'Notifications on — click to turn off'
|
? 'Notificaciones activadas — clic para desactivar'
|
||||||
: 'Notify me when this medicine is added to a pharmacy'
|
: 'Notificarme cuando este medicamento esté en una farmacia'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{subscribed && currentUser ? '🔔' : '🔕'}
|
{subscribed && currentUser ? '🔔' : '🔕'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="medicine-card-body">
|
<div className="medicine-card-body">
|
||||||
<p><strong>Active Ingredient:</strong> {medicine.active_ingredient}</p>
|
<p><strong>Principio Activo:</strong> {medicine.active_ingredient}</p>
|
||||||
<p><strong>Dosage:</strong> {medicine.dosage} • <strong>Form:</strong> {medicine.form}</p>
|
<p><strong>Dosis:</strong> {medicine.dosage} • <strong>Forma:</strong> {medicine.form}</p>
|
||||||
{error && <p className="notify-error" onClick={e => e.stopPropagation()}>{error}</p>}
|
{error && <p className="notify-error" onClick={e => e.stopPropagation()}>{error}</p>}
|
||||||
</div>
|
</div>
|
||||||
<div className="medicine-card-footer">
|
<div className="medicine-card-footer">
|
||||||
<span className="view-pharmacies">View pharmacies →</span>
|
<span className="view-pharmacies">Ver farmacias →</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ function PharmacyList({ pharmacies, loading, userPosition, medicine, currentUser
|
|||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="loading-pharmacies">
|
<div className="loading-pharmacies">
|
||||||
<p>Loading pharmacies...</p>
|
<p>Cargando farmacias...</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -21,7 +21,7 @@ function PharmacyList({ pharmacies, loading, userPosition, medicine, currentUser
|
|||||||
if (pharmacies.length === 0) {
|
if (pharmacies.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="no-pharmacies">
|
<div className="no-pharmacies">
|
||||||
<p>No pharmacies found selling this medicine</p>
|
<p>No se encontraron farmacias con este medicamento</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -29,7 +29,7 @@ function PharmacyList({ pharmacies, loading, userPosition, medicine, currentUser
|
|||||||
return (
|
return (
|
||||||
<div className="pharmacy-list">
|
<div className="pharmacy-list">
|
||||||
<h3 className="pharmacy-list-title">
|
<h3 className="pharmacy-list-title">
|
||||||
Available at {pharmacies.length} {pharmacies.length === 1 ? 'pharmacy' : 'pharmacies'}
|
Disponible en {pharmacies.length} {pharmacies.length === 1 ? 'farmacia' : 'farmacias'}
|
||||||
</h3>
|
</h3>
|
||||||
<div className="pharmacy-grid">
|
<div className="pharmacy-grid">
|
||||||
{pharmacies.map((pharmacy) => {
|
{pharmacies.map((pharmacy) => {
|
||||||
@@ -76,7 +76,7 @@ function PharmacyCard({ pharmacy, distanceKm, medicine, currentUser, onLoginRequ
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!supported) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
if (busy) return;
|
if (busy) return;
|
||||||
@@ -92,7 +92,7 @@ function PharmacyCard({ pharmacy, distanceKm, medicine, currentUser, onLoginRequ
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[notify] pharmacy toggle failed:', 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 {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
@@ -117,17 +117,17 @@ function PharmacyCard({ pharmacy, distanceKm, medicine, currentUser, onLoginRequ
|
|||||||
aria-pressed={subscribed && !!currentUser}
|
aria-pressed={subscribed && !!currentUser}
|
||||||
aria-label={
|
aria-label={
|
||||||
!currentUser
|
!currentUser
|
||||||
? 'Login to enable notifications'
|
? 'Inicia sesión para activar notificaciones'
|
||||||
: subscribed
|
: subscribed
|
||||||
? 'Stop notifications for this pharmacy'
|
? 'Desactivar notificaciones para esta farmacia'
|
||||||
: 'Notify me when this medicine arrives at this pharmacy'
|
: 'Notificarme cuando llegue a esta farmacia'
|
||||||
}
|
}
|
||||||
title={
|
title={
|
||||||
!currentUser
|
!currentUser
|
||||||
? 'Login to enable notifications'
|
? 'Inicia sesión para activar notificaciones'
|
||||||
: subscribed
|
: subscribed
|
||||||
? 'Notifications on for this pharmacy — click to turn off'
|
? 'Notificaciones activadas para esta farmacia — clic para desactivar'
|
||||||
: 'Notify me when this medicine arrives at this pharmacy'
|
: 'Notificarme cuando llegue a esta farmacia'
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{subscribed && currentUser ? '🔔' : '🔕'}
|
{subscribed && currentUser ? '🔔' : '🔕'}
|
||||||
@@ -152,7 +152,7 @@ function PharmacyCard({ pharmacy, distanceKm, medicine, currentUser, onLoginRequ
|
|||||||
)}
|
)}
|
||||||
{pharmacy.stock !== undefined && (
|
{pharmacy.stock !== undefined && (
|
||||||
<span className={`stock ${pharmacy.stock > 20 ? 'in-stock' : pharmacy.stock > 0 ? 'low-stock' : 'out-of-stock'}`}>
|
<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>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ function SavedNotifications({ onClose }) {
|
|||||||
].sort((a, b) => (b.created_at || '').localeCompare(a.created_at || ''));
|
].sort((a, b) => (b.created_at || '').localeCompare(a.created_at || ''));
|
||||||
setItems(merged);
|
setItems(merged);
|
||||||
} catch (err) {
|
} 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 {
|
} finally {
|
||||||
if (!cancelled) setLoading(false);
|
if (!cancelled) setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -42,7 +42,7 @@ function SavedNotifications({ onClose }) {
|
|||||||
if (!res.ok && res.status !== 204) throw new Error(`HTTP ${res.status}`);
|
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)));
|
setItems(prev => prev.filter(i => !(i.scope === item.scope && i.id === item.id)));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message || 'Could not remove notification');
|
setError(err.message || 'No se pudo eliminar la notificación');
|
||||||
} finally {
|
} finally {
|
||||||
setBusyId(null);
|
setBusyId(null);
|
||||||
}
|
}
|
||||||
@@ -52,15 +52,15 @@ function SavedNotifications({ onClose }) {
|
|||||||
<div className="saved-notifications-backdrop" onClick={onClose}>
|
<div className="saved-notifications-backdrop" onClick={onClose}>
|
||||||
<div className="saved-notifications-modal" onClick={e => e.stopPropagation()}>
|
<div className="saved-notifications-modal" onClick={e => e.stopPropagation()}>
|
||||||
<div className="saved-notifications-header">
|
<div className="saved-notifications-header">
|
||||||
<h2>🔔 Saved Notifications</h2>
|
<h2>🔔 Notificaciones Guardadas</h2>
|
||||||
<button className="saved-notifications-close" onClick={onClose} aria-label="Close">×</button>
|
<button className="saved-notifications-close" onClick={onClose} aria-label="Cerrar">×</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="saved-notifications-body">
|
<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 && <p className="saved-notifications-error">{error}</p>}
|
||||||
{!loading && !error && items.length === 0 && (
|
{!loading && !error && items.length === 0 && (
|
||||||
<p className="saved-notifications-empty">
|
<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>
|
</p>
|
||||||
)}
|
)}
|
||||||
{!loading && !error && items.length > 0 && (
|
{!loading && !error && items.length > 0 && (
|
||||||
@@ -77,7 +77,7 @@ function SavedNotifications({ onClose }) {
|
|||||||
{item.scope === 'pharmacy' ? (
|
{item.scope === 'pharmacy' ? (
|
||||||
<>
|
<>
|
||||||
<span className="saved-notifications-chip saved-notifications-chip--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>
|
</span>
|
||||||
{item.pharmacy_address && (
|
{item.pharmacy_address && (
|
||||||
<span className="saved-notifications-address">{item.pharmacy_address}</span>
|
<span className="saved-notifications-address">{item.pharmacy_address}</span>
|
||||||
@@ -85,7 +85,7 @@ function SavedNotifications({ onClose }) {
|
|||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<span className="saved-notifications-chip">
|
<span className="saved-notifications-chip">
|
||||||
Any pharmacy
|
Cualquier farmacia
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -95,9 +95,9 @@ function SavedNotifications({ onClose }) {
|
|||||||
className="saved-notifications-remove"
|
className="saved-notifications-remove"
|
||||||
onClick={() => handleDelete(item)}
|
onClick={() => handleDelete(item)}
|
||||||
disabled={busyId === key}
|
disabled={busyId === key}
|
||||||
aria-label="Remove notification"
|
aria-label="Eliminar notificación"
|
||||||
>
|
>
|
||||||
{busyId === key ? '…' : 'Remove'}
|
{busyId === key ? '…' : 'Eliminar'}
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ function TopBar({ title, showBack, onBack, rightAction }) {
|
|||||||
{showBack ? (
|
{showBack ? (
|
||||||
<h1 className="topbar-title">{title}</h1>
|
<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 className="topbar-right">{rightAction || <div className="topbar-spacer" />}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -25,14 +25,14 @@ function LoginForm({ onLogin }) {
|
|||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(data.error || 'Login failed');
|
throw new Error(data.error || 'Inicio de sesión fallido');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Success - notify parent component
|
// Success - notify parent component
|
||||||
onLogin(data.user);
|
onLogin(data.user);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Login error:', error);
|
console.error('Login error:', error);
|
||||||
setError(error.message || 'Invalid username or password');
|
setError(error.message || 'Usuario o contraseña inválidos');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -42,8 +42,8 @@ function LoginForm({ onLogin }) {
|
|||||||
<div className="login-container">
|
<div className="login-container">
|
||||||
<div className="login-box">
|
<div className="login-box">
|
||||||
<div className="login-header">
|
<div className="login-header">
|
||||||
<h2>🔐 Admin Login</h2>
|
<h2>🔐 Acceso Administración</h2>
|
||||||
<p>Please enter your credentials to access the admin panel</p>
|
<p>Introduce tus credenciales para acceder al panel de administración</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="login-form">
|
<form onSubmit={handleSubmit} className="login-form">
|
||||||
@@ -54,13 +54,13 @@ function LoginForm({ onLogin }) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="username">Username</label>
|
<label htmlFor="username">Usuario</label>
|
||||||
<input
|
<input
|
||||||
id="username"
|
id="username"
|
||||||
type="text"
|
type="text"
|
||||||
value={username}
|
value={username}
|
||||||
onChange={(e) => setUsername(e.target.value)}
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
placeholder="Enter username"
|
placeholder="Introduce usuario"
|
||||||
required
|
required
|
||||||
autoFocus
|
autoFocus
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
@@ -68,13 +68,13 @@ function LoginForm({ onLogin }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="password">Password</label>
|
<label htmlFor="password">Contraseña</label>
|
||||||
<input
|
<input
|
||||||
id="password"
|
id="password"
|
||||||
type="password"
|
type="password"
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
placeholder="Enter password"
|
placeholder="Introduce contraseña"
|
||||||
required
|
required
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
/>
|
/>
|
||||||
@@ -85,16 +85,16 @@ function LoginForm({ onLogin }) {
|
|||||||
className="login-button"
|
className="login-button"
|
||||||
disabled={loading || !username || !password}
|
disabled={loading || !username || !password}
|
||||||
>
|
>
|
||||||
{loading ? 'Logging in...' : 'Login'}
|
{loading ? 'Iniciando sesión...' : 'Iniciar sesión'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div className="login-footer">
|
<div className="login-footer">
|
||||||
<p className="help-text">
|
<p className="help-text">
|
||||||
Default credentials: <code>admin</code> / <code>admin123</code>
|
Credenciales por defecto: <code>admin</code> / <code>admin123</code>
|
||||||
</p>
|
</p>
|
||||||
<p className="warning-text">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ function MedicineManagement() {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.name === 'AbortError') return;
|
if (error.name === 'AbortError') return;
|
||||||
console.error('Error searching medicines:', error);
|
console.error('Error searching medicines:', error);
|
||||||
alert('Error searching medicines from CIMA API');
|
alert('Error al buscar medicamentos en la API CIMA');
|
||||||
} finally {
|
} finally {
|
||||||
if (!controller.signal.aborted) setLoading(false);
|
if (!controller.signal.aborted) setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -44,7 +44,7 @@ function MedicineManagement() {
|
|||||||
return (
|
return (
|
||||||
<div className="admin-section">
|
<div className="admin-section">
|
||||||
<div className="section-header">
|
<div className="section-header">
|
||||||
<h2>Search Medicines (CIMA API)</h2>
|
<h2>Buscar Medicamentos (API CIMA)</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="info-box">
|
<div className="info-box">
|
||||||
@@ -54,7 +54,7 @@ function MedicineManagement() {
|
|||||||
|
|
||||||
<div className="admin-form">
|
<div className="admin-form">
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>Search for medicines</label>
|
<label>Buscar medicamentos</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
@@ -64,11 +64,11 @@ function MedicineManagement() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loading && <div className="loading">Searching CIMA API...</div>}
|
{loading && <div className="loading">Buscando en API CIMA...</div>}
|
||||||
|
|
||||||
{!loading && medicines.length > 0 && (
|
{!loading && medicines.length > 0 && (
|
||||||
<div className="admin-list">
|
<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) => (
|
{medicines.map((medicine) => (
|
||||||
<div key={medicine.nregistro} className="admin-item">
|
<div key={medicine.nregistro} className="admin-item">
|
||||||
<div className="item-content">
|
<div className="item-content">
|
||||||
|
|||||||
@@ -56,10 +56,10 @@ function haversineMeters(lat1, lon1, lat2, lon2) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const REGION_PRESETS = [
|
const REGION_PRESETS = [
|
||||||
{ id: 'custom', label: 'Custom coordinates', lat: '', lon: '', radio: '' },
|
{ id: 'custom', label: 'Coordenadas personalizadas', lat: '', lon: '', radio: '' },
|
||||||
{
|
{
|
||||||
id: 'rubi',
|
id: 'rubi',
|
||||||
label: 'Example: Rubí area (1.5 km)',
|
label: 'Ejemplo: Área de Rubí (1.5 km)',
|
||||||
lat: '41.5631',
|
lat: '41.5631',
|
||||||
lon: '2.0038',
|
lon: '2.0038',
|
||||||
radio: '1500',
|
radio: '1500',
|
||||||
@@ -76,16 +76,16 @@ async function geocodeErrorMessage(response) {
|
|||||||
}
|
}
|
||||||
if (typeof body.error === 'string' && body.error.trim()) return body.error;
|
if (typeof body.error === 'string' && body.error.trim()) return body.error;
|
||||||
if (response.status === 401) {
|
if (response.status === 401) {
|
||||||
return 'Session expired or not logged in. Sign in again on Admin, then retry.';
|
return 'Sesión expirada o no has iniciado sesión. Inicia sesión de nuevo en el panel Admin y reintenta.';
|
||||||
}
|
}
|
||||||
if (response.status === 404) {
|
if (response.status === 404) {
|
||||||
const looksLikeHtml = /<!DOCTYPE|<html[\s>]/i.test(text || '');
|
const looksLikeHtml = /<!DOCTYPE|<html[\s>]/i.test(text || '');
|
||||||
if (looksLikeHtml) {
|
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() {
|
function PharmacyManagement() {
|
||||||
@@ -132,7 +132,7 @@ function PharmacyManagement() {
|
|||||||
setPharmacies(data);
|
setPharmacies(data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching pharmacies:', error);
|
console.error('Error fetching pharmacies:', error);
|
||||||
alert('Error loading pharmacies');
|
alert('Error al cargar farmacias');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -165,7 +165,7 @@ function PharmacyManagement() {
|
|||||||
e?.preventDefault();
|
e?.preventDefault();
|
||||||
const q = cityQuery.trim();
|
const q = cityQuery.trim();
|
||||||
if (!q) {
|
if (!q) {
|
||||||
setCityLookupMessage({ type: 'err', text: 'Enter a city or place name.' });
|
setCityLookupMessage({ type: 'err', text: 'Introduce una ciudad o lugar.' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setCityLookupLoading(true);
|
setCityLookupLoading(true);
|
||||||
@@ -253,7 +253,7 @@ function PharmacyManagement() {
|
|||||||
const lon = parseFloat(regionLon);
|
const lon = parseFloat(regionLon);
|
||||||
const radio = parseFloat(regionRadio);
|
const radio = parseFloat(regionRadio);
|
||||||
if (!Number.isFinite(lat) || !Number.isFinite(lon) || !Number.isFinite(radio)) {
|
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', {
|
const response = await fetch('/api/admin/pharmacies/import-external', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -307,7 +307,7 @@ function PharmacyManagement() {
|
|||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const error = await response.json();
|
const error = await response.json();
|
||||||
throw new Error(error.error || 'Failed to update pharmacy');
|
throw new Error(error.error || 'Error al actualizar farmacia');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const response = await fetch('/api/admin/pharmacies', {
|
const response = await fetch('/api/admin/pharmacies', {
|
||||||
@@ -319,16 +319,16 @@ function PharmacyManagement() {
|
|||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const error = await response.json();
|
const error = await response.json();
|
||||||
throw new Error(error.error || 'Failed to create pharmacy');
|
throw new Error(error.error || 'Error al crear farmacia');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
resetForm();
|
resetForm();
|
||||||
fetchPharmacies();
|
fetchPharmacies();
|
||||||
alert(editingPharmacy ? 'Pharmacy updated successfully!' : 'Pharmacy added successfully!');
|
alert(editingPharmacy ? '¡Farmacia actualizada!' : '¡Farmacia añadida!');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error saving pharmacy:', error);
|
console.error('Error saving pharmacy:', error);
|
||||||
alert(`Error saving pharmacy: ${error.message}`);
|
alert(`Error al guardar farmacia: ${error.message}`);
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
@@ -348,7 +348,7 @@ function PharmacyManagement() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (id) => {
|
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 {
|
try {
|
||||||
const response = await fetch(`/api/admin/pharmacies/${id}`, {
|
const response = await fetch(`/api/admin/pharmacies/${id}`, {
|
||||||
@@ -356,13 +356,13 @@ function PharmacyManagement() {
|
|||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) throw new Error('Failed to delete pharmacy');
|
if (!response.ok) throw new Error('Error al eliminar farmacia');
|
||||||
|
|
||||||
fetchPharmacies();
|
fetchPharmacies();
|
||||||
alert('Pharmacy deleted successfully!');
|
alert('¡Farmacia eliminada!');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting pharmacy:', error);
|
console.error('Error deleting pharmacy:', error);
|
||||||
alert('Error deleting pharmacy');
|
alert('Error al eliminar farmacia');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -391,7 +391,7 @@ function PharmacyManagement() {
|
|||||||
return (
|
return (
|
||||||
<div className="admin-section">
|
<div className="admin-section">
|
||||||
<div className="section-header">
|
<div className="section-header">
|
||||||
<h2>Manage Pharmacies</h2>
|
<h2>Gestionar Farmacias</h2>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn-primary"
|
className="btn-primary"
|
||||||
@@ -400,16 +400,16 @@ function PharmacyManagement() {
|
|||||||
setShowForm(true);
|
setShowForm(true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
+ Add New Pharmacy
|
+ Añadir Nueva Farmacia
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pharmacy-tools-card">
|
<div className="pharmacy-tools-card">
|
||||||
<h3>City, region & import</h3>
|
<h3>Ciudad, región e importación</h3>
|
||||||
<p className="pharmacy-tools-hint">
|
<p className="pharmacy-tools-hint">
|
||||||
<strong>Find city</strong> sets latitude, longitude and radius for the map filter and for imports.
|
<strong>Buscar ciudad</strong> establece latitud, longitud y radio para el filtro de mapa y las importaciones.
|
||||||
Choose a <strong>data source</strong> below: <strong>OpenStreetMap</strong> is free (no key);{' '}
|
Elige una <strong>fuente de datos</strong>: <strong>OpenStreetMap</strong> es gratuita (sin clave);{' '}
|
||||||
<strong>Open data URL</strong> loads JSON you host (array or GeoJSON). Geocoding uses{' '}
|
<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">
|
<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noreferrer">
|
||||||
OpenStreetMap
|
OpenStreetMap
|
||||||
</a>{' '}
|
</a>{' '}
|
||||||
@@ -418,11 +418,11 @@ function PharmacyManagement() {
|
|||||||
|
|
||||||
<form className="city-lookup-form" onSubmit={handleCityLookup}>
|
<form className="city-lookup-form" onSubmit={handleCityLookup}>
|
||||||
<div className="form-group city-lookup-input-wrap">
|
<div className="form-group city-lookup-input-wrap">
|
||||||
<label htmlFor="city-finder">Find city</label>
|
<label htmlFor="city-finder">Buscar ciudad</label>
|
||||||
<input
|
<input
|
||||||
id="city-finder"
|
id="city-finder"
|
||||||
type="search"
|
type="search"
|
||||||
placeholder="e.g. Rubí, Spain — or Madrid, Valencia…"
|
placeholder="Ej: Rubí, Madrid, Valencia…"
|
||||||
value={cityQuery}
|
value={cityQuery}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setCityQuery(e.target.value);
|
setCityQuery(e.target.value);
|
||||||
@@ -436,7 +436,7 @@ function PharmacyManagement() {
|
|||||||
className="btn-secondary city-lookup-submit"
|
className="btn-secondary city-lookup-submit"
|
||||||
disabled={cityLookupLoading}
|
disabled={cityLookupLoading}
|
||||||
>
|
>
|
||||||
{cityLookupLoading ? 'Looking up…' : 'Look up city'}
|
{cityLookupLoading ? 'Buscando…' : 'Buscar ciudad'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
{cityLookupMessage && (
|
{cityLookupMessage && (
|
||||||
@@ -449,7 +449,7 @@ function PharmacyManagement() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="region-presets">
|
<div className="region-presets">
|
||||||
<label htmlFor="region-preset">Area preset</label>
|
<label htmlFor="region-preset">Preset de área</label>
|
||||||
<select
|
<select
|
||||||
id="region-preset"
|
id="region-preset"
|
||||||
value={regionPreset}
|
value={regionPreset}
|
||||||
@@ -465,7 +465,7 @@ function PharmacyManagement() {
|
|||||||
|
|
||||||
<div className="region-grid">
|
<div className="region-grid">
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="region-lat">Latitude</label>
|
<label htmlFor="region-lat">Latitud</label>
|
||||||
<input
|
<input
|
||||||
id="region-lat"
|
id="region-lat"
|
||||||
type="text"
|
type="text"
|
||||||
@@ -476,7 +476,7 @@ function PharmacyManagement() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="region-lon">Longitude</label>
|
<label htmlFor="region-lon">Longitud</label>
|
||||||
<input
|
<input
|
||||||
id="region-lon"
|
id="region-lon"
|
||||||
type="text"
|
type="text"
|
||||||
@@ -487,7 +487,7 @@ function PharmacyManagement() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="region-radio">Radius (m)</label>
|
<label htmlFor="region-radio">Radio (m)</label>
|
||||||
<input
|
<input
|
||||||
id="region-radio"
|
id="region-radio"
|
||||||
type="text"
|
type="text"
|
||||||
@@ -501,7 +501,7 @@ function PharmacyManagement() {
|
|||||||
|
|
||||||
<div className="import-mode-row">
|
<div className="import-mode-row">
|
||||||
<div className="form-group import-mode-select-wrap">
|
<div className="form-group import-mode-select-wrap">
|
||||||
<label htmlFor="import-mode">Data source</label>
|
<label htmlFor="import-mode">Fuente de datos</label>
|
||||||
<select
|
<select
|
||||||
id="import-mode"
|
id="import-mode"
|
||||||
value={importMode}
|
value={importMode}
|
||||||
@@ -510,16 +510,16 @@ function PharmacyManagement() {
|
|||||||
setImportFeedback(null);
|
setImportFeedback(null);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<option value="osm">OpenStreetMap (Overpass, free)</option>
|
<option value="osm">OpenStreetMap (Overpass, gratuito)</option>
|
||||||
<option value="webhook">n8n webhook (legacy)</option>
|
<option value="webhook">n8n webhook (heredado)</option>
|
||||||
<option value="openData">Open data JSON URL</option>
|
<option value="openData">URL de datos abiertos JSON</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{importMode === 'openData' && (
|
{importMode === 'openData' && (
|
||||||
<div className="form-group open-data-url-row">
|
<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
|
<input
|
||||||
id="open-data-url"
|
id="open-data-url"
|
||||||
type="url"
|
type="url"
|
||||||
@@ -539,12 +539,12 @@ function PharmacyManagement() {
|
|||||||
disabled={importing}
|
disabled={importing}
|
||||||
>
|
>
|
||||||
{importing
|
{importing
|
||||||
? 'Importing…'
|
? 'Importando…'
|
||||||
: importMode === 'webhook'
|
: importMode === 'webhook'
|
||||||
? 'Import from webhook'
|
? 'Importar desde webhook'
|
||||||
: importMode === 'openData'
|
: importMode === 'openData'
|
||||||
? 'Import from URL'
|
? 'Importar desde URL'
|
||||||
: `Import from ${importMode === 'osm' ? 'Overpass' : 'OpenStreetMap'}`}
|
: `Importar desde ${importMode === 'osm' ? 'Overpass' : 'OpenStreetMap'}`}
|
||||||
</button>
|
</button>
|
||||||
<label className="filter-region-toggle">
|
<label className="filter-region-toggle">
|
||||||
<input
|
<input
|
||||||
@@ -552,7 +552,7 @@ function PharmacyManagement() {
|
|||||||
checked={filterByRegion}
|
checked={filterByRegion}
|
||||||
onChange={(e) => setFilterByRegion(e.target.checked)}
|
onChange={(e) => setFilterByRegion(e.target.checked)}
|
||||||
/>
|
/>
|
||||||
Show only pharmacies inside radius
|
Mostrar solo farmacias dentro del radio
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -568,10 +568,10 @@ function PharmacyManagement() {
|
|||||||
|
|
||||||
{showForm && (
|
{showForm && (
|
||||||
<form className="admin-form" onSubmit={handleSubmit}>
|
<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">
|
<div className="form-group">
|
||||||
<label>Name *</label>
|
<label>Nombre *</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={formData.name}
|
value={formData.name}
|
||||||
@@ -581,7 +581,7 @@ function PharmacyManagement() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>Address *</label>
|
<label>Dirección *</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={formData.address}
|
value={formData.address}
|
||||||
@@ -591,7 +591,7 @@ function PharmacyManagement() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>Phone</label>
|
<label>Teléfono</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={formData.phone}
|
value={formData.phone}
|
||||||
@@ -601,7 +601,7 @@ function PharmacyManagement() {
|
|||||||
|
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>Latitude</label>
|
<label>Latitud</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
step="any"
|
step="any"
|
||||||
@@ -611,7 +611,7 @@ function PharmacyManagement() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>Longitude</label>
|
<label>Longitud</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
step="any"
|
step="any"
|
||||||
@@ -622,8 +622,8 @@ function PharmacyManagement() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<fieldset className="hours-editor">
|
<fieldset className="hours-editor">
|
||||||
<legend>Opening hours</legend>
|
<legend>Horario de apertura</legend>
|
||||||
<p className="hours-editor-hint">Leave a day checked as <em>Closed</em> if the pharmacy doesn't open that day.</p>
|
<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) => {
|
{DAY_KEYS.map((day) => {
|
||||||
const d = hoursDraft[day];
|
const d = hoursDraft[day];
|
||||||
return (
|
return (
|
||||||
@@ -635,14 +635,14 @@ function PharmacyManagement() {
|
|||||||
checked={d.closed}
|
checked={d.closed}
|
||||||
onChange={(e) => updateDay(day, { closed: e.target.checked })}
|
onChange={(e) => updateDay(day, { closed: e.target.checked })}
|
||||||
/>
|
/>
|
||||||
Closed
|
Cerrado
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="time"
|
type="time"
|
||||||
value={d.open}
|
value={d.open}
|
||||||
disabled={d.closed}
|
disabled={d.closed}
|
||||||
onChange={(e) => updateDay(day, { open: e.target.value })}
|
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>
|
<span className="hours-sep">–</span>
|
||||||
<input
|
<input
|
||||||
@@ -650,7 +650,7 @@ function PharmacyManagement() {
|
|||||||
value={d.close}
|
value={d.close}
|
||||||
disabled={d.closed}
|
disabled={d.closed}
|
||||||
onChange={(e) => updateDay(day, { close: e.target.value })}
|
onChange={(e) => updateDay(day, { close: e.target.value })}
|
||||||
aria-label={`${DAY_LABEL[day]} closes at`}
|
aria-label={`${DAY_LABEL[day]} cierra a las`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -659,28 +659,28 @@ function PharmacyManagement() {
|
|||||||
|
|
||||||
<div className="form-actions">
|
<div className="form-actions">
|
||||||
<button type="submit" className="btn-primary" disabled={saving}>
|
<button type="submit" className="btn-primary" disabled={saving}>
|
||||||
{saving ? 'Saving...' : editingPharmacy ? 'Update' : 'Add'} Pharmacy
|
{saving ? 'Guardando...' : editingPharmacy ? 'Actualizar' : 'Añadir'} Farmacia
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="btn-secondary" onClick={resetForm} disabled={saving}>
|
<button type="button" className="btn-secondary" onClick={resetForm} disabled={saving}>
|
||||||
Cancel
|
Cancelar
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="loading">Loading pharmacies...</div>
|
<div className="loading">Cargando farmacias...</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="admin-list">
|
<div className="admin-list">
|
||||||
<p className="list-meta">
|
<p className="list-meta">
|
||||||
Showing {displayedPharmacies.length} of {pharmacies.length} pharmacies
|
Mostrando {displayedPharmacies.length} de {pharmacies.length} farmacias
|
||||||
{filterByRegion && ' (inside radius)'}
|
{filterByRegion && ' (dentro del radio)'}
|
||||||
</p>
|
</p>
|
||||||
{displayedPharmacies.length === 0 ? (
|
{displayedPharmacies.length === 0 ? (
|
||||||
<p className="empty-state">
|
<p className="empty-state">
|
||||||
{pharmacies.length === 0
|
{pharmacies.length === 0
|
||||||
? 'No pharmacies yet. Import from webhook or add one manually.'
|
? 'Aún no hay farmacias. Importa desde webhook o añade una manualmente.'
|
||||||
: 'No pharmacies in this radius with coordinates. Widen the radius, look up a different city, or turn off the region filter.'}
|
: 'No hay farmacias en este radio con coordenadas. Amplía el radio, busca otra ciudad o desactiva el filtro de región.'}
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
displayedPharmacies.map((pharmacy) => (
|
displayedPharmacies.map((pharmacy) => (
|
||||||
@@ -697,10 +697,10 @@ function PharmacyManagement() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="item-actions">
|
<div className="item-actions">
|
||||||
<button type="button" className="btn-edit" onClick={() => handleEdit(pharmacy)}>
|
<button type="button" className="btn-edit" onClick={() => handleEdit(pharmacy)}>
|
||||||
Edit
|
Editar
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="btn-delete" onClick={() => handleDelete(pharmacy.id)}>
|
<button type="button" className="btn-delete" onClick={() => handleDelete(pharmacy.id)}>
|
||||||
Delete
|
Eliminar
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -111,7 +111,7 @@ function PharmacyMedicineLink() {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
if (!selectedMedicine) {
|
if (!selectedMedicine) {
|
||||||
alert('Please select a medicine first');
|
alert('Por favor, selecciona un medicamento primero');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,16 +131,16 @@ function PharmacyMedicineLink() {
|
|||||||
body: JSON.stringify(payload)
|
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();
|
resetForm();
|
||||||
if (selectedPharmacy) {
|
if (selectedPharmacy) {
|
||||||
fetchPharmacyMedicines(selectedPharmacy.id);
|
fetchPharmacyMedicines(selectedPharmacy.id);
|
||||||
}
|
}
|
||||||
alert('Medicine linked to pharmacy successfully!');
|
alert('¡Medicamento vinculado a la farmacia correctamente!');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error linking medicine:', 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 })
|
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);
|
fetchPharmacyMedicines(selectedPharmacy.id);
|
||||||
alert('Updated successfully!');
|
alert('¡Actualizado correctamente!');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error updating:', error);
|
console.error('Error updating:', error);
|
||||||
alert('Error updating');
|
alert('Error al actualizar');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (id) => {
|
const handleDelete = async (id) => {
|
||||||
if (!confirm('Remove this medicine from the pharmacy?')) return;
|
if (!confirm('¿Eliminar este medicamento de la farmacia?')) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`/api/admin/pharmacy-medicines/${id}`, {
|
const response = await fetch(`/api/admin/pharmacy-medicines/${id}`, {
|
||||||
@@ -172,13 +172,13 @@ function PharmacyMedicineLink() {
|
|||||||
credentials: 'include'
|
credentials: 'include'
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response.ok) throw new Error('Failed to delete');
|
if (!response.ok) throw new Error('Error al eliminar');
|
||||||
|
|
||||||
fetchPharmacyMedicines(selectedPharmacy.id);
|
fetchPharmacyMedicines(selectedPharmacy.id);
|
||||||
alert('Medicine removed from pharmacy!');
|
alert('¡Medicamento eliminado de la farmacia!');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error deleting:', error);
|
console.error('Error deleting:', error);
|
||||||
alert('Error removing medicine');
|
alert('Error al eliminar medicamento');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -216,11 +216,11 @@ function PharmacyMedicineLink() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="admin-section">
|
<div className="admin-section">
|
||||||
<h2>Link Medicine to Pharmacy</h2>
|
<h2>Vincular Medicamento a Farmacia</h2>
|
||||||
|
|
||||||
<form className="admin-form" onSubmit={handleSubmit}>
|
<form className="admin-form" onSubmit={handleSubmit}>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>Pharmacy *</label>
|
<label>Farmacia *</label>
|
||||||
<input
|
<input
|
||||||
ref={pharmacyInputRef}
|
ref={pharmacyInputRef}
|
||||||
type="text"
|
type="text"
|
||||||
@@ -235,7 +235,7 @@ function PharmacyMedicineLink() {
|
|||||||
}}
|
}}
|
||||||
onFocus={() => setPharmacyDropdownOpen(true)}
|
onFocus={() => setPharmacyDropdownOpen(true)}
|
||||||
onBlur={() => setTimeout(() => setPharmacyDropdownOpen(false), 150)}
|
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"
|
autoComplete="off"
|
||||||
required={!selectedPharmacy}
|
required={!selectedPharmacy}
|
||||||
/>
|
/>
|
||||||
@@ -243,7 +243,7 @@ function PharmacyMedicineLink() {
|
|||||||
<div className="medicine-search-results">
|
<div className="medicine-search-results">
|
||||||
{filteredPharmacies.length === 0 ? (
|
{filteredPharmacies.length === 0 ? (
|
||||||
<div className="search-result-item search-result-item--empty">
|
<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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
filteredPharmacies.map((pharmacy) => (
|
filteredPharmacies.map((pharmacy) => (
|
||||||
@@ -264,14 +264,14 @@ function PharmacyMedicineLink() {
|
|||||||
<p>✅ Selected: <strong>{selectedPharmacy.name}</strong></p>
|
<p>✅ Selected: <strong>{selectedPharmacy.name}</strong></p>
|
||||||
<p className="medicine-details">{selectedPharmacy.address}</p>
|
<p className="medicine-details">{selectedPharmacy.address}</p>
|
||||||
<button type="button" className="btn-small" onClick={clearPharmacy}>
|
<button type="button" className="btn-small" onClick={clearPharmacy}>
|
||||||
Change pharmacy
|
Cambiar farmacia
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>Search Medicine (CIMA API) *</label>
|
<label>Buscar Medicamento (API CIMA) *</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={medicineSearch}
|
value={medicineSearch}
|
||||||
@@ -279,10 +279,10 @@ function PharmacyMedicineLink() {
|
|||||||
setMedicineSearch(e.target.value);
|
setMedicineSearch(e.target.value);
|
||||||
setSelectedMedicine(null);
|
setSelectedMedicine(null);
|
||||||
}}
|
}}
|
||||||
placeholder="Type to search medicines from CIMA..."
|
placeholder="Escribe para buscar medicamentos en CIMA..."
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
{searching && <p className="loading-text">Searching...</p>}
|
{searching && <p className="loading-text">Buscando...</p>}
|
||||||
|
|
||||||
{medicineResults.length > 0 && !selectedMedicine && (
|
{medicineResults.length > 0 && !selectedMedicine && (
|
||||||
<div className="medicine-search-results">
|
<div className="medicine-search-results">
|
||||||
@@ -316,7 +316,7 @@ function PharmacyMedicineLink() {
|
|||||||
setMedicineSearch('');
|
setMedicineSearch('');
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Change medicine
|
Cambiar medicamento
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -324,7 +324,7 @@ function PharmacyMedicineLink() {
|
|||||||
|
|
||||||
<div className="form-row">
|
<div className="form-row">
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label>Price (€)</label>
|
<label>Precio (€)</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
step="0.01"
|
step="0.01"
|
||||||
@@ -347,21 +347,21 @@ function PharmacyMedicineLink() {
|
|||||||
|
|
||||||
<div className="form-actions">
|
<div className="form-actions">
|
||||||
<button type="submit" className="btn-primary">
|
<button type="submit" className="btn-primary">
|
||||||
Link Medicine
|
Vincular Medicamento
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="btn-secondary" onClick={resetForm}>
|
<button type="button" className="btn-secondary" onClick={resetForm}>
|
||||||
Reset
|
Reiniciar
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{selectedPharmacy && (
|
{selectedPharmacy && (
|
||||||
<div className="pharmacy-medicines-section">
|
<div className="pharmacy-medicines-section">
|
||||||
<h3>Medicines at {selectedPharmacy.name}</h3>
|
<h3>Medicamentos en {selectedPharmacy.name}</h3>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="loading">Loading...</div>
|
<div className="loading">Cargando...</div>
|
||||||
) : pharmacyMedicines.length === 0 ? (
|
) : 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">
|
<div className="admin-list">
|
||||||
{pharmacyMedicines.map((pm) => (
|
{pharmacyMedicines.map((pm) => (
|
||||||
@@ -369,7 +369,7 @@ function PharmacyMedicineLink() {
|
|||||||
<div className="item-content">
|
<div className="item-content">
|
||||||
<h4>{pm.medicine_name}</h4>
|
<h4>{pm.medicine_name}</h4>
|
||||||
<p>
|
<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}
|
<strong> Stock:</strong> {pm.stock || 0}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -377,17 +377,17 @@ function PharmacyMedicineLink() {
|
|||||||
<button
|
<button
|
||||||
className="btn-edit"
|
className="btn-edit"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const newPrice = prompt('Enter new price:', pm.price || '');
|
const newPrice = prompt('Introduce nuevo precio:', pm.price || '');
|
||||||
const newStock = prompt('Enter new stock:', pm.stock || '0');
|
const newStock = prompt('Introduce nuevo stock:', pm.stock || '0');
|
||||||
if (newPrice !== null && newStock !== null) {
|
if (newPrice !== null && newStock !== null) {
|
||||||
handleUpdate(pm.id, newPrice ? parseFloat(newPrice) : null, parseInt(newStock) || 0);
|
handleUpdate(pm.id, newPrice ? parseFloat(newPrice) : null, parseInt(newStock) || 0);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Update
|
Actualizar
|
||||||
</button>
|
</button>
|
||||||
<button className="btn-delete" onClick={() => handleDelete(pm.id)}>
|
<button className="btn-delete" onClick={() => handleDelete(pm.id)}>
|
||||||
Remove
|
Eliminar
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+2
-2
@@ -14,10 +14,10 @@ self.addEventListener('push', (event) => {
|
|||||||
try {
|
try {
|
||||||
data = event.data.json();
|
data = event.data.json();
|
||||||
} catch {
|
} 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 = {
|
const options = {
|
||||||
body: data.body || '',
|
body: data.body || '',
|
||||||
icon: '/icon.svg',
|
icon: '/icon.svg',
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
//
|
//
|
||||||
// Required env vars (Vite injects VITE_* at build time):
|
// Required env vars (Vite injects VITE_* at build time):
|
||||||
// VITE_FARO_ENDPOINT — e.g. http://localhost:4318
|
// 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_ENV — e.g. production | staging | development
|
||||||
// VITE_FARO_APP_VERSION — optional, defaults to 1.0.0
|
// VITE_FARO_APP_VERSION — optional, defaults to 1.0.0
|
||||||
|
|
||||||
@@ -29,7 +29,7 @@ export function initFaro() {
|
|||||||
return;
|
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 environment = import.meta.env.VITE_FARO_ENV || 'production';
|
||||||
const version = import.meta.env.VITE_FARO_APP_VERSION || '1.0.0';
|
const version = import.meta.env.VITE_FARO_APP_VERSION || '1.0.0';
|
||||||
|
|
||||||
|
|||||||
@@ -13,11 +13,11 @@ export function haversineKm(lat1, lon1, lat2, lon2) {
|
|||||||
export function getUserPosition() {
|
export function getUserPosition() {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
if (!navigator.geolocation) {
|
if (!navigator.geolocation) {
|
||||||
reject(new Error('Geolocation not supported by this browser'));
|
reject(new Error('Geolocalización no compatible con este navegador'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (typeof window !== 'undefined' && window.isSecureContext === false) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
navigator.geolocation.getCurrentPosition(
|
navigator.geolocation.getCurrentPosition(
|
||||||
|
|||||||
+13
-13
@@ -1,12 +1,12 @@
|
|||||||
const DAYS = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'];
|
const DAYS = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'];
|
||||||
const DAY_LABELS = {
|
const DAY_LABELS = {
|
||||||
sun: 'Sunday',
|
sun: 'Domingo',
|
||||||
mon: 'Monday',
|
mon: 'Lunes',
|
||||||
tue: 'Tuesday',
|
tue: 'Martes',
|
||||||
wed: 'Wednesday',
|
wed: 'Miércoles',
|
||||||
thu: 'Thursday',
|
thu: 'Jueves',
|
||||||
fri: 'Friday',
|
fri: 'Viernes',
|
||||||
sat: 'Saturday',
|
sat: 'Sábado',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DAY_KEYS = DAYS;
|
export const DAY_KEYS = DAYS;
|
||||||
@@ -33,8 +33,8 @@ function findNextOpen(hours, now) {
|
|||||||
const range = hours[day];
|
const range = hours[day];
|
||||||
if (Array.isArray(range) && range.length === 2) {
|
if (Array.isArray(range) && range.length === 2) {
|
||||||
const openStr = range[0];
|
const openStr = range[0];
|
||||||
if (offset === 1) return `tomorrow at ${openStr}`;
|
if (offset === 1) return `mañana a las ${openStr}`;
|
||||||
return `${DAY_LABELS[day]} at ${openStr}`;
|
return `${DAY_LABELS[day]} a las ${openStr}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -49,7 +49,7 @@ export function getOpenStatus(rawHours, now = new Date()) {
|
|||||||
|
|
||||||
if (!Array.isArray(range) || range.length !== 2) {
|
if (!Array.isArray(range) || range.length !== 2) {
|
||||||
const next = findNextOpen(hours, now);
|
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]);
|
const openMins = parseHM(range[0]);
|
||||||
@@ -59,13 +59,13 @@ export function getOpenStatus(rawHours, now = new Date()) {
|
|||||||
const nowMins = now.getHours() * 60 + now.getMinutes();
|
const nowMins = now.getHours() * 60 + now.getMinutes();
|
||||||
|
|
||||||
if (nowMins < openMins) {
|
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) {
|
if (nowMins >= closeMins) {
|
||||||
const next = findNextOpen(hours, now);
|
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() {
|
export function emptyHours() {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const STORAGE_KEY = 'farmafinder.subscriptions.v1';
|
const STORAGE_KEY = 'farmaclic.subscriptions.v1';
|
||||||
|
|
||||||
export function getStoredSubscriptions() {
|
export function getStoredSubscriptions() {
|
||||||
try {
|
try {
|
||||||
@@ -38,12 +38,12 @@ export async function getVapidPublicKey() {
|
|||||||
const res = await fetch('/api/notifications/vapid-public-key');
|
const res = await fetch('/api/notifications/vapid-public-key');
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const message = res.status === 503
|
const message = res.status === 503
|
||||||
? 'Push notifications are not configured on the server yet'
|
? 'Las notificaciones push no están configuradas en el servidor'
|
||||||
: `Could not fetch VAPID key (HTTP ${res.status})`;
|
: `No se pudo obtener la clave VAPID (HTTP ${res.status})`;
|
||||||
throw new Error(message);
|
throw new Error(message);
|
||||||
}
|
}
|
||||||
const { publicKey } = await res.json();
|
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;
|
return publicKey;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,17 +58,17 @@ export function urlBase64ToUint8Array(base64String) {
|
|||||||
|
|
||||||
async function getOrCreateSubscription() {
|
async function getOrCreateSubscription() {
|
||||||
if (!pushSupported()) {
|
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) {
|
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;
|
const reg = await navigator.serviceWorker.ready;
|
||||||
let sub = await reg.pushManager.getSubscription();
|
let sub = await reg.pushManager.getSubscription();
|
||||||
if (sub) return sub;
|
if (sub) return sub;
|
||||||
|
|
||||||
if (Notification.permission === 'denied') {
|
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') {
|
if (Notification.permission === 'default') {
|
||||||
const result = await Notification.requestPermission();
|
const result = await Notification.requestPermission();
|
||||||
@@ -98,7 +98,7 @@ export async function subscribeToPush(medicineNregistro, medicineName, pharmacyI
|
|||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const detail = await res.text().catch(() => '');
|
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)]);
|
setStoredSubscriptions([...getStoredSubscriptions(), storageKey(medicineNregistro, pharmacyId)]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ function AdminView() {
|
|||||||
if (loading) {
|
if (loading) {
|
||||||
return (
|
return (
|
||||||
<div className="app-main">
|
<div className="app-main">
|
||||||
<div className="loading">Checking authentication...</div>
|
<div className="loading">Comprobando autenticación...</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -68,8 +68,8 @@ function AdminView() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<header className="app-header">
|
<header className="app-header">
|
||||||
<h1>⚙️ Admin Panel</h1>
|
<h1>⚙️ Panel de Administración</h1>
|
||||||
<p>Authentication required</p>
|
<p>Autenticación requerida</p>
|
||||||
</header>
|
</header>
|
||||||
<main className="app-main">
|
<main className="app-main">
|
||||||
<LoginForm onLogin={handleLogin} />
|
<LoginForm onLogin={handleLogin} />
|
||||||
@@ -83,13 +83,13 @@ function AdminView() {
|
|||||||
<header className="app-header">
|
<header className="app-header">
|
||||||
<div className="admin-header-content">
|
<div className="admin-header-content">
|
||||||
<div>
|
<div>
|
||||||
<h1>⚙️ Admin Panel</h1>
|
<h1>⚙️ Panel de Administración</h1>
|
||||||
<p>Manage pharmacies and medicines</p>
|
<p>Gestiona farmacias y medicamentos</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="admin-user-info">
|
<div className="admin-user-info">
|
||||||
<span>👤 {user?.username}</span>
|
<span>👤 {user?.username}</span>
|
||||||
<button className="logout-button" onClick={handleLogout}>
|
<button className="logout-button" onClick={handleLogout}>
|
||||||
Logout
|
Cerrar sesión
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -101,19 +101,19 @@ function AdminView() {
|
|||||||
className={`admin-tab ${activeTab === 'pharmacies' ? 'active' : ''}`}
|
className={`admin-tab ${activeTab === 'pharmacies' ? 'active' : ''}`}
|
||||||
onClick={() => setActiveTab('pharmacies')}
|
onClick={() => setActiveTab('pharmacies')}
|
||||||
>
|
>
|
||||||
🏥 Pharmacies
|
🏥 Farmacias
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`admin-tab ${activeTab === 'medicines' ? 'active' : ''}`}
|
className={`admin-tab ${activeTab === 'medicines' ? 'active' : ''}`}
|
||||||
onClick={() => setActiveTab('medicines')}
|
onClick={() => setActiveTab('medicines')}
|
||||||
>
|
>
|
||||||
💊 Medicines
|
💊 Medicamentos
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`admin-tab ${activeTab === 'link' ? 'active' : ''}`}
|
className={`admin-tab ${activeTab === 'link' ? 'active' : ''}`}
|
||||||
onClick={() => setActiveTab('link')}
|
onClick={() => setActiveTab('link')}
|
||||||
>
|
>
|
||||||
🔗 Link Medicine to Pharmacy
|
🔗 Vincular Medicamento a Farmacia
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -20,16 +20,26 @@
|
|||||||
|
|
||||||
.home-logo-wrapper {
|
.home-logo-wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
gap: 1rem;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-logo {
|
.home-logo {
|
||||||
width: min(16rem, 70vw);
|
width: min(10rem, 50vw);
|
||||||
height: auto;
|
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) {
|
@media (max-height: 700px) {
|
||||||
.home-logo {
|
.home-logo {
|
||||||
width: min(12rem, 60vw);
|
width: min(12rem, 60vw);
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ function HomeView({ onScanClick, onSearchClick }) {
|
|||||||
<div className="home-view">
|
<div className="home-view">
|
||||||
<div className="home-hero">
|
<div className="home-hero">
|
||||||
<div className="home-logo-wrapper">
|
<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>
|
</div>
|
||||||
<p className="home-desc">Encuentra tus medicamentos en farmacias cercanas</p>
|
<p className="home-desc">Encuentra tus medicamentos en farmacias cercanas</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -108,12 +108,12 @@ function PublicView({
|
|||||||
console.error('[location] error', err);
|
console.error('[location] error', err);
|
||||||
let msg;
|
let msg;
|
||||||
if (err && typeof err.code === 'number') {
|
if (err && typeof err.code === 'number') {
|
||||||
if (err.code === 1) msg = 'Location permission denied — allow it in your browser settings';
|
if (err.code === 1) msg = 'Permiso de ubicación denegado — actívalo en la configuración del navegador';
|
||||||
else if (err.code === 2) msg = 'Location unavailable — check OS location services / WiFi';
|
else if (err.code === 2) msg = 'Ubicación no disponible — comprueba los servicios de ubicación/WiFi';
|
||||||
else if (err.code === 3) msg = 'Location request timed out — try again';
|
else if (err.code === 3) msg = 'La solicitud de ubicación expiró — inténtalo de nuevo';
|
||||||
else msg = `Geolocation error (code ${err.code})`;
|
else msg = `Error de geolocalización (código ${err.code})`;
|
||||||
} else {
|
} 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);
|
setLocationError(msg);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -172,22 +172,22 @@ function PublicView({
|
|||||||
setPharmacies([]);
|
setPharmacies([]);
|
||||||
setScreen('home');
|
setScreen('home');
|
||||||
}}
|
}}
|
||||||
aria-label="Back to home"
|
aria-label="Volver al inicio"
|
||||||
>
|
>
|
||||||
← Home
|
← Inicio
|
||||||
</button>
|
</button>
|
||||||
<h1>💊 FarmaFinder</h1>
|
<h1>💊 FarmaClic</h1>
|
||||||
<p>Find your medicine at nearby pharmacies</p>
|
<p>Encuentra tu medicamento en farmacias cercanas</p>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main className="app-main">
|
<main className="app-main">
|
||||||
<SearchBar
|
<SearchBar
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={setSearchQuery}
|
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 && (
|
{searchQuery && !selectedMedicine && (
|
||||||
<MedicineResults
|
<MedicineResults
|
||||||
@@ -204,9 +204,9 @@ function PublicView({
|
|||||||
<div className="medicine-info">
|
<div className="medicine-info">
|
||||||
<h2>{selectedMedicine.name}</h2>
|
<h2>{selectedMedicine.name}</h2>
|
||||||
<div className="medicine-details">
|
<div className="medicine-details">
|
||||||
<span><strong>Active Ingredient:</strong> {selectedMedicine.active_ingredient}</span>
|
<span><strong>Principio Activo:</strong> {selectedMedicine.active_ingredient}</span>
|
||||||
<span><strong>Dosage:</strong> {selectedMedicine.dosage}</span>
|
<span><strong>Dosis:</strong> {selectedMedicine.dosage}</span>
|
||||||
<span><strong>Form:</strong> {selectedMedicine.form}</span>
|
<span><strong>Forma:</strong> {selectedMedicine.form}</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
className="back-button"
|
className="back-button"
|
||||||
@@ -215,7 +215,7 @@ function PublicView({
|
|||||||
setPharmacies([]);
|
setPharmacies([]);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
← Back to search
|
← Volver a búsqueda
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -227,15 +227,15 @@ function PublicView({
|
|||||||
disabled={locating}
|
disabled={locating}
|
||||||
>
|
>
|
||||||
{locating
|
{locating
|
||||||
? '📍 Locating…'
|
? '📍 Localizando…'
|
||||||
: sortByDistance
|
: sortByDistance
|
||||||
? '📍 Sorted by distance · Reset'
|
? '📍 Ordenado por distancia · Reset'
|
||||||
: hasSavedCoords
|
: hasSavedCoords
|
||||||
? '📍 Sort by your saved location'
|
? '📍 Ordenar por ubicación guardada'
|
||||||
: '📍 Sort by distance'}
|
: '📍 Ordenar por distancia'}
|
||||||
</button>
|
</button>
|
||||||
{sortByDistance && positionSource === 'profile' && (
|
{sortByDistance && positionSource === 'profile' && (
|
||||||
<span className="location-source">Using your saved address</span>
|
<span className="location-source">Usando tu dirección guardada</span>
|
||||||
)}
|
)}
|
||||||
{locationError && (
|
{locationError && (
|
||||||
<span className="location-error">{locationError}</span>
|
<span className="location-error">{locationError}</span>
|
||||||
|
|||||||
@@ -17,9 +17,9 @@ export default defineConfig({
|
|||||||
navigateFallback: 'index.html',
|
navigateFallback: 'index.html',
|
||||||
},
|
},
|
||||||
manifest: {
|
manifest: {
|
||||||
name: 'FarmaFinder',
|
name: 'FarmaClic',
|
||||||
short_name: 'FarmaFinder',
|
short_name: 'FarmaClic',
|
||||||
description: 'Find pharmacies that stock the medicine you need',
|
description: 'Encuentra medicamentos en farmacias cercanas',
|
||||||
theme_color: '#00450d',
|
theme_color: '#00450d',
|
||||||
background_color: '#f8fafb',
|
background_color: '#f8fafb',
|
||||||
display: 'standalone',
|
display: 'standalone',
|
||||||
|
|||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "farma-finder",
|
"name": "farma-clic",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "FarmaFinder - Full stack application",
|
"description": "FarmaClic - Full stack application",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "npm-run-all --parallel dev:backend dev:frontend",
|
"dev": "npm-run-all --parallel dev:backend dev:frontend",
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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"]
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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")
|
||||||
@@ -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:
|
||||||
@@ -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:
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
from .main import app
|
||||||
|
|
||||||
|
__all__ = ["app"]
|
||||||
@@ -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"
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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)
|
||||||
@@ -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
Reference in New Issue
Block a user