24 Commits

Author SHA1 Message Date
Ichitux 4b00c9aa4f Merge branch 'main' into fix/mobile-view
Run Tests on Branches / Detect Changes (push) Successful in 15s
Run Tests on Branches / Backend Tests (push) Has been skipped
Run Tests on Branches / Frontend Tests (push) Has been skipped
Run Tests on Branches / Frontend Mobile Tests (push) Successful in 2m33s
Run Tests on Branches / Parapharmacy API Tests (push) Has been skipped
Run Tests on Branches / PIP Platform Tests (push) Has been skipped
2026-08-18 12:16:18 +00:00
Ichitux fa53d5855d Merge pull request 'feat: add forgot password and username recovery flow' (#59) from feat/password-recovery into main
Build & Push Docker Images / Backend Tests (push) Successful in 2m1s
Build & Push Docker Images / Parapharmacy API Tests (push) Has been skipped
Build & Push Docker Images / Pip Platform Tests (push) Has been skipped
Build & Push Docker Images / Detect Changes (push) Successful in 14s
Build & Push Docker Images / Frontend Tests (push) Successful in 1m55s
Build & Push Docker Images / Build Parapharmacy API (push) Has been skipped
Build & Push Docker Images / Build Pip Platform (push) Has been skipped
Build & Push Docker Images / Build Frontend (push) Successful in 1m10s
Build & Push Docker Images / Build Backend (push) Successful in 3m17s
Build & Push Docker Images / Deploy (push) Successful in 3m12s
Reviewed-on: #59
2026-08-18 09:08:02 +00:00
Antoni Nuñez Romeu d68463548f Patentes internacionales + Forgot Pwd
Run Tests on Branches / Detect Changes (push) Successful in 19s
Run Tests on Branches / Backend Tests (push) Successful in 2m46s
Run Tests on Branches / PIP Platform Tests (push) Has been skipped
Run Tests on Branches / Frontend Tests (push) Successful in 2m24s
Run Tests on Branches / Frontend Mobile Tests (push) Has been skipped
Run Tests on Branches / Parapharmacy API Tests (push) Has been skipped
2026-08-18 11:01:54 +02:00
Antoni Nuñez Romeu 87df61ab15 feat: add forgot password and username recovery flow
Run Tests on Branches / Detect Changes (push) Successful in 14s
Run Tests on Branches / Backend Tests (push) Successful in 2m24s
Run Tests on Branches / Frontend Tests (push) Successful in 1m57s
Run Tests on Branches / Frontend Mobile Tests (push) Has been skipped
Run Tests on Branches / Parapharmacy API Tests (push) Has been skipped
Run Tests on Branches / PIP Platform Tests (push) Has been skipped
- Add Mailpit SMTP container to docker-compose for dev email capture
- Add nodemailer dependency for email sending
- Add password_reset_tokens table (PG + SQLite)
- Add POST /api/auth/forgot-username endpoint
- Add POST /api/auth/forgot-password endpoint (token-based, 1h expiry)
- Add POST /api/auth/reset-password endpoint
- Create ForgotPasswordModal component (choose mode → email → sent)
- Create ResetPasswordView (token-based new password form)
- Add forgot/recovery links to LoginModal
- Add i18n translations (ES/CA) for the full flow
2026-07-27 15:57:54 +02:00
Ichitux 86962dfa3a Merge branch 'main' into fix/mobile-view
Run Tests on Branches / Detect Changes (push) Successful in 13s
Run Tests on Branches / Backend Tests (push) Has been skipped
Run Tests on Branches / Frontend Tests (push) Has been skipped
Run Tests on Branches / Frontend Mobile Tests (push) Successful in 2m13s
Run Tests on Branches / Parapharmacy API Tests (push) Has been skipped
Run Tests on Branches / PIP Platform Tests (push) Has been skipped
2026-07-27 13:26:33 +00:00
Ichitux 271d23b072 Merge pull request 'feat: add healthchecks to all docker compose services' (#58) from feat/docker-healthchecks into main
Build & Push Docker Images / Backend Tests (push) Has been skipped
Build & Push Docker Images / Parapharmacy API Tests (push) Has been skipped
Build & Push Docker Images / Build Parapharmacy API (push) Has been skipped
Build & Push Docker Images / Build Pip Platform (push) Has been skipped
Build & Push Docker Images / Build Frontend (push) Has been skipped
Build & Push Docker Images / Detect Changes (push) Successful in 10s
Build & Push Docker Images / Pip Platform Tests (push) Has been skipped
Build & Push Docker Images / Frontend Tests (push) Has been skipped
Build & Push Docker Images / Build Backend (push) Has been skipped
Build & Push Docker Images / Deploy (push) Has been skipped
Reviewed-on: #58
2026-07-27 13:23:59 +00:00
Antoni Nuñez Romeu 258839dfc8 feat: add healthchecks to all docker compose services
Run Tests on Branches / Detect Changes (push) Successful in 13s
Run Tests on Branches / Backend Tests (push) Has been skipped
Run Tests on Branches / Frontend Tests (push) Has been skipped
Run Tests on Branches / Frontend Mobile Tests (push) Has been skipped
Run Tests on Branches / PIP Platform Tests (push) Has been skipped
Run Tests on Branches / Parapharmacy API Tests (push) Has been skipped
2026-07-27 15:23:16 +02:00
Ichitux ddd8ae8537 Merge pull request 'feat: pharmacy hours status and open now filter' (#57) from feat/pharmacy-hours-status into main
Build & Push Docker Images / Detect Changes (push) Successful in 13s
Build & Push Docker Images / Backend Tests (push) Successful in 2m6s
Build & Push Docker Images / Parapharmacy API Tests (push) Has been skipped
Build & Push Docker Images / Pip Platform Tests (push) Has been skipped
Build & Push Docker Images / Frontend Tests (push) Successful in 1m58s
Build & Push Docker Images / Build Backend (push) Successful in 2m47s
Build & Push Docker Images / Build Parapharmacy API (push) Has been skipped
Build & Push Docker Images / Build Pip Platform (push) Has been skipped
Build & Push Docker Images / Build Frontend (push) Successful in 1m5s
Build & Push Docker Images / Deploy (push) Successful in 2m54s
Reviewed-on: #57
2026-07-27 12:45:08 +00:00
Antoni Nuñez Romeu e3e7d2f60b feat: pharmacy hours status and open now filter
Run Tests on Branches / Detect Changes (push) Successful in 17s
Run Tests on Branches / Backend Tests (push) Successful in 2m44s
Run Tests on Branches / Frontend Tests (push) Successful in 1m59s
Run Tests on Branches / Frontend Mobile Tests (push) Has been skipped
Run Tests on Branches / Parapharmacy API Tests (push) Has been skipped
Run Tests on Branches / PIP Platform Tests (push) Has been skipped
- Add is_open_now and is_always_open helpers in backend
- Add backend endpoint enrichment with is_open and is_24h fields
- Add client-side getOpenStatus with 24h, opens-at, opens-tomorrow support
- Add open-now filter toggle in PublicView and SearchView
- Add pharmacy no-hours fallback display
- Add sticky pharmacy controls on scroll
- Add admin hours editor with 24h toggle
- Add translations (es/ca) for all hour-related strings
- Add backend tests for hours logic and pharmacy endpoint
- Remove unused backup test files
2026-07-27 14:32:05 +02:00
Ichitux d43cbfa44e Merge pull request 'Security/dependency hardening 2026 07 22' (#56) from security/dependency-hardening-2026-07-22 into main
Build & Push Docker Images / Detect Changes (push) Successful in 11s
Build & Push Docker Images / Backend Tests (push) Successful in 2m16s
Build & Push Docker Images / Parapharmacy API Tests (push) Successful in 2m10s
Build & Push Docker Images / Pip Platform Tests (push) Has been skipped
Build & Push Docker Images / Frontend Tests (push) Has been skipped
Build & Push Docker Images / Build Backend (push) Successful in 35s
Build & Push Docker Images / Build Parapharmacy API (push) Successful in 1m44s
Build & Push Docker Images / Build Pip Platform (push) Successful in 2m44s
Build & Push Docker Images / Build Frontend (push) Has been skipped
Build & Push Docker Images / Deploy (push) Successful in 34s
Reviewed-on: #56
2026-07-24 09:27:21 +00:00
Antoni Nuñez Romeu 573d2e5d35 ci: inject .env files from Gitea variables during deploy
Run Tests on Branches / Detect Changes (push) Successful in 15s
Run Tests on Branches / Backend Tests (push) Successful in 2m31s
Run Tests on Branches / Frontend Tests (push) Has been skipped
Run Tests on Branches / Frontend Mobile Tests (push) Has been skipped
Run Tests on Branches / Parapharmacy API Tests (push) Successful in 2m24s
Run Tests on Branches / PIP Platform Tests (push) Has been skipped
Replace the basic .env.example sync with a deploy script that fetches
repo variables from the Gitea API and writes them to the correct .env
files, preserving comments from .env.example templates.
2026-07-24 09:56:27 +02:00
Ichitux c8b830017b Merge branch 'main' into fix/mobile-view
Run Tests on Branches / Detect Changes (push) Successful in 14s
Run Tests on Branches / Backend Tests (push) Has been skipped
Run Tests on Branches / Parapharmacy API Tests (push) Has been skipped
Run Tests on Branches / PIP Platform Tests (push) Has been skipped
Run Tests on Branches / Frontend Tests (push) Has been skipped
Run Tests on Branches / Frontend Mobile Tests (push) Successful in 1m53s
2026-07-23 12:21:11 +00:00
Antoni Nuñez Romeu 076ca2d590 Fixes in deployment and passwords
Run Tests on Branches / Frontend Mobile Tests (push) Has been skipped
Run Tests on Branches / Parapharmacy API Tests (push) Successful in 2m2s
Run Tests on Branches / Detect Changes (push) Successful in 14s
Run Tests on Branches / Backend Tests (push) Successful in 1m58s
Run Tests on Branches / Frontend Tests (push) Has been skipped
Run Tests on Branches / PIP Platform Tests (push) Has been skipped
2026-07-22 18:28:47 +02:00
Antoni Nuñez Romeu 1f340c1aa1 docs: record dependency hardening baseline 2026-07-22 17:46:49 +02:00
Ichitux 67a79724ae Merge pull request 'security: harden production configuration and routes' (#55) from security/hotfixes-2026-07-22 into main
Build & Push Docker Images / Detect Changes (push) Successful in 12s
Build & Push Docker Images / Parapharmacy API Tests (push) Successful in 2m3s
Build & Push Docker Images / Frontend Tests (push) Successful in 2m1s
Build & Push Docker Images / Backend Tests (push) Successful in 2m4s
Build & Push Docker Images / Pip Platform Tests (push) Has been skipped
Build & Push Docker Images / Build Backend (push) Successful in 35s
Build & Push Docker Images / Build Pip Platform (push) Failing after 22s
Build & Push Docker Images / Build Parapharmacy API (push) Failing after 20s
Build & Push Docker Images / Build Frontend (push) Successful in 42s
Build & Push Docker Images / Deploy (push) Has been skipped
Reviewed-on: #55
2026-07-22 15:34:57 +00:00
Antoni Nuñez Romeu 849763896d security: harden production configuration and routes
Run Tests on Branches / Detect Changes (push) Successful in 12s
Run Tests on Branches / Frontend Tests (push) Successful in 2m12s
Run Tests on Branches / Frontend Mobile Tests (push) Has been skipped
Run Tests on Branches / Parapharmacy API Tests (push) Successful in 2m2s
Run Tests on Branches / PIP Platform Tests (push) Has been skipped
Run Tests on Branches / Backend Tests (push) Successful in 2m8s
2026-07-22 17:24:54 +02:00
Ichitux e641a35b82 Merge branch 'main' into fix/mobile-view
Run Tests on Branches / Detect Changes (push) Failing after 31s
Run Tests on Branches / Backend Tests (push) Has been skipped
Run Tests on Branches / Frontend Tests (push) Has been skipped
Run Tests on Branches / Frontend Mobile Tests (push) Has been skipped
Run Tests on Branches / Parapharmacy API Tests (push) Has been skipped
Run Tests on Branches / PIP Platform Tests (push) Has been skipped
2026-07-22 09:57:58 +00:00
Ichitux f60af5f6b2 Merge pull request 'feat(faro): rewrite dashboards to use Tempo instead of Loki' (#54) from fix/otel-metrics-and-monitoring-dashboards into main
Build & Push Docker Images / Backend Tests (push) Has been skipped
Build & Push Docker Images / Parapharmacy API Tests (push) Has been skipped
Build & Push Docker Images / Detect Changes (push) Successful in 10s
Build & Push Docker Images / Pip Platform Tests (push) Has been skipped
Build & Push Docker Images / Frontend Tests (push) Has been skipped
Build & Push Docker Images / Build Backend (push) Has been skipped
Build & Push Docker Images / Build Parapharmacy API (push) Has been skipped
Build & Push Docker Images / Build Frontend (push) Has been skipped
Build & Push Docker Images / Build Pip Platform (push) Has been skipped
Build & Push Docker Images / Deploy (push) Has been skipped
Reviewed-on: #54
2026-07-21 15:42:29 +00:00
Ichitux 5da34769d5 Merge branch 'main' into fix/otel-metrics-and-monitoring-dashboards
Run Tests on Branches / Detect Changes (push) Successful in 12s
Run Tests on Branches / Backend Tests (push) Has been skipped
Run Tests on Branches / Frontend Tests (push) Has been skipped
Run Tests on Branches / Frontend Mobile Tests (push) Has been skipped
Run Tests on Branches / Parapharmacy API Tests (push) Has been skipped
Run Tests on Branches / PIP Platform Tests (push) Has been skipped
2026-07-21 15:41:57 +00:00
Antoni Nuñez Romeu fa842d3f6f feat(faro): rewrite dashboards to use Tempo instead of Loki
Run Tests on Branches / Detect Changes (push) Successful in 13s
Run Tests on Branches / Backend Tests (push) Has been skipped
Run Tests on Branches / Frontend Mobile Tests (push) Has been skipped
Run Tests on Branches / Parapharmacy API Tests (push) Has been skipped
Run Tests on Branches / Frontend Tests (push) Has been skipped
Run Tests on Branches / PIP Platform Tests (push) Has been skipped
Faro SDK sends traces (spans with events) via OTLP, not OTLP logs or
metrics. Rewrote all 3 Faro dashboards to use Tempo TraceQL queries
instead of Loki LogQL.

- faro-overview: page views, exceptions, console errors, navigation
- faro-js-errors: exception traces, console error traces
- faro-web-vitals: LCP, FCP, CLS, TTFB via web-vitals span events

All panels now query Tempo (uid P214B5B846CF3925F) with TraceQL.
2026-07-21 17:39:19 +02:00
Ichitux ff36748634 Merge branch 'main' into fix/mobile-view
Run Tests on Branches / Detect Changes (push) Successful in 11s
Run Tests on Branches / Backend Tests (push) Has been skipped
Run Tests on Branches / Frontend Tests (push) Has been skipped
Run Tests on Branches / Frontend Mobile Tests (push) Successful in 1m45s
Run Tests on Branches / Parapharmacy API Tests (push) Has been skipped
Run Tests on Branches / PIP Platform Tests (push) Has been skipped
2026-07-21 13:11:15 +00:00
Ichitux 06ea844fd0 Merge pull request 'fix(otel): lazy-init metrics and fix Grafana dashboards' (#53) from fix/otel-metrics-and-monitoring-dashboards into main
Build & Push Docker Images / Backend Tests (push) Successful in 1m43s
Build & Push Docker Images / Parapharmacy API Tests (push) Has been skipped
Build & Push Docker Images / Pip Platform Tests (push) Has been skipped
Build & Push Docker Images / Frontend Tests (push) Has been skipped
Build & Push Docker Images / Detect Changes (push) Successful in 12s
Build & Push Docker Images / Build Backend (push) Successful in 35s
Build & Push Docker Images / Build Parapharmacy API (push) Has been skipped
Build & Push Docker Images / Build Pip Platform (push) Has been skipped
Build & Push Docker Images / Build Frontend (push) Has been skipped
Build & Push Docker Images / Deploy (push) Successful in 35s
Reviewed-on: #53
2026-07-21 13:11:07 +00:00
Ichitux 795d935413 Remove Google Maps vs OSM
Run Tests on Branches / PIP Platform Tests (push) Has been skipped
Run Tests on Branches / Detect Changes (push) Successful in 13s
Run Tests on Branches / Backend Tests (push) Has been skipped
Run Tests on Branches / Frontend Tests (push) Has been skipped
Run Tests on Branches / Frontend Mobile Tests (push) Successful in 1m35s
Run Tests on Branches / Parapharmacy API Tests (push) Has been skipped
2026-07-17 16:11:10 +00:00
Ichitux 2f24dd4ff8 Faro & OSM
Run Tests on Branches / Detect Changes (push) Successful in 11s
Run Tests on Branches / Backend Tests (push) Has been skipped
Run Tests on Branches / Frontend Tests (push) Has been skipped
Run Tests on Branches / Frontend Mobile Tests (push) Successful in 1m53s
Run Tests on Branches / Parapharmacy API Tests (push) Has been skipped
Run Tests on Branches / PIP Platform Tests (push) Has been skipped
2026-07-17 15:58:46 +00:00
98 changed files with 9691 additions and 427 deletions
+6
View File
@@ -0,0 +1,6 @@
custom-apis:
- name: "localai"
endpoint: "http://10.25.2.33:18080"
model: "ornith-1.0-9b"
api_key: "your-api-key" # only if auth is enabled
+5
View File
@@ -0,0 +1,5 @@
{
"anthropicBaseURL": "http://10.25.2.33:18080/v1",
"anthropicApiKey": "localai",
"anthropicCustomModelOption": "ornith-1.0-9b"
}
+2
View File
@@ -20,6 +20,8 @@ N8N_EMAIL=admin@farmafinder.com
PARAPHARMACY_API_URL=http://parapharmacy-api:3002 PARAPHARMACY_API_URL=http://parapharmacy-api:3002
PARAPHARMACY_CORS_ORIGIN=http://localhost:4000 PARAPHARMACY_CORS_ORIGIN=http://localhost:4000
MONGODB_URI=mongodb://mongodb:27017/parapharmacy MONGODB_URI=mongodb://mongodb:27017/parapharmacy
INGEST_API_KEY=dev-ingest-key-change-me
ADMIN_API_KEY=dev-admin-key-change-me
# Expo Push Notifications (mobile) # Expo Push Notifications (mobile)
EXPO_ACCESS_TOKEN= EXPO_ACCESS_TOKEN=
+7 -21
View File
@@ -254,30 +254,16 @@ jobs:
set -euo pipefail set -euo pipefail
git pull git pull
- name: Sync .env files from .env.example - name: Inject .env files from Gitea variables
working-directory: /docker/FarmaFinder working-directory: /docker/FarmaFinder
env:
GITEA_TOKEN: ${{ vars.GITEA_TOKEN }}
GITEA_OWNER: Ichitux
GITEA_REPO: FarmaFinder
WORK_DIR: /docker/FarmaFinder
run: | run: |
set -euo pipefail set -euo pipefail
while IFS= read -r env_example; do bash scripts/deploy-env.sh
dir=$(dirname "$env_example")
env_file="$dir/.env"
if [ ! -f "$env_file" ]; then
cp "$env_example" "$env_file"
echo "[env-sync] Created $env_file from $env_example"
else
while IFS= read -r line || [ -n "$line" ]; do
case "$line" in
''|\#*) continue ;;
esac
key=$(echo "$line" | cut -d'=' -f1)
if ! grep -qF "$key=" "$env_file"; then
echo "$line" >> "$env_file"
echo "[env-sync] Added missing key '$key' to $env_file"
fi
done < "$env_example"
fi
done < <(find apps -name ".env.example" -type f)
- name: Deploy containers - name: Deploy containers
working-directory: /docker/FarmaFinder working-directory: /docker/FarmaFinder
+1 -1
View File
@@ -1 +1 @@
{"pid":568607,"startedAt":1784046408782} {"pid":3854791,"startedAt":1784736547988}
@@ -0,0 +1,63 @@
# International Prior Art Search
## Project: FarmaFinder
This document summarizes the international prior-art position for the technical concept described in the FarmaFinder patent materials.
### Technical scope
The invention concerns a computer-implemented system and method for identifying a therapeutic need from the scan of an individual health card or equivalent patient-related identifier, associating that result with an authenticated user, comparing the inferred need with geolocated pharmacy inventory, and generating supply-related actions when the required product is not available.
### Main categories of prior art identified
The following classes of prior-art documents are relevant to the search:
1. Medicine search platforms that allow users to locate medicines by name or active ingredient.
2. Pharmacy locator systems that display nearby pharmacies and, in some cases, opening hours, distance, or approximate availability.
3. Inventory management systems used by pharmacies, distributors, or health-related retail networks.
4. Digital health applications that store user profiles, location data, reminders, or treatment-related information.
5. Reservation, referral, or notification workflows that allow a user or a pharmacy to react when a product is out of stock.
### Distinguishing technical features
Based on the current project documentation, the present invention is distinguished by the combination of the following technical features in a single workflow:
- secure capture of a health-card identifier or equivalent patient-related credential;
- verification of the captured data and association with an authenticated user profile;
- automatic derivation of a medicine reference or therapeutic need;
- real-time comparison against pharmacy stock data from geolocated pharmacies;
- ranking of pharmacies according to availability, distance, and opening status;
- automatic generation of reserve, notification, order, or referral actions when stock is insufficient;
- technical traceability of the event across the processing chain.
### Assessment
The reviewed prior-art categories appear to disclose individual parts of the technical problem, but not the complete integrated architecture described above in a single system. In particular, the combination of:
- patient-context capture through a health identifier,
- automated therapeutic inference,
- geolocated stock comparison, and
- machine-generated supply actions
does not appear to be disclosed as a unified technical chain in the documents reviewed at the project level.
### Suggested search focus for the European search phase
For the European Patent Office search phase, the search should focus on documents relating to:
- patient identifier scanning;
- health-card reading or optical capture of patient credentials;
- medicine search and pharmacy availability systems;
- pharmacy reservation and referral workflows;
- location-based ranking of pharmacies;
- automated inventory-driven notification or order generation;
- health-related digital assistants that combine user context with stock availability.
### Working conclusion
On the basis of the current project documentation, the invention is presented as a computer-implemented technical solution that integrates identification, verification, geolocation, inventory contrast, and automated supply actions in one workflow. No single reference has been identified at the project level that clearly discloses the full combination of these features.
### Date
13 August 2026
@@ -0,0 +1,110 @@
%PDF-1.4
1 0 obj
<< /Type /Font /Subtype /Type1 /BaseFont /Times-Roman /Encoding /WinAnsiEncoding >>
endobj
2 0 obj
<< /Type /Font /Subtype /Type1 /BaseFont /Times-Bold /Encoding /WinAnsiEncoding >>
endobj
3 0 obj
<< /Type /Font /Subtype /Type1 /BaseFont /Times-Italic /Encoding /WinAnsiEncoding >>
endobj
4 0 obj
<< /Type /Pages /Kids [7 0 R 8 0 R] /Count 2 >>
endobj
5 0 obj
<< /Length 2089 >>
stream
BT /F2 18 Tf 138 787.89 Td (International Prior Art Search) Tj ET
BT /F3 12 Tf 156 761.39 Td (Project: FarmaFinder) Tj ET
BT /F1 11.5 Tf 56 726.39 Td (This document summarizes the international prior-art position for the technical concept) Tj ET
BT /F1 11.5 Tf 56 712.015 Td (described in the FarmaFinder patent materials.) Tj ET
BT /F2 13 Tf 56 695.64 Td (Technical scope) Tj ET
0.3 w 56 675.39 m 539.28 675.39 l S
BT /F1 11.5 Tf 56 667.39 Td (The invention concerns a computer-implemented system and method for identifying a) Tj ET
BT /F1 11.5 Tf 56 653.015 Td (therapeutic need from the scan of an individual health card or equivalent) Tj ET
BT /F1 11.5 Tf 56 638.64 Td (patient-related identifier, associating that result with an authenticated user,) Tj ET
BT /F1 11.5 Tf 56 624.265 Td (comparing the inferred need with geolocated pharmacy inventory, and generating) Tj ET
BT /F1 11.5 Tf 56 609.89 Td (supply-related actions when the required product is not available.) Tj ET
BT /F2 13 Tf 56 593.515 Td (Main categories of prior art identified) Tj ET
0.3 w 56 573.265 m 539.28 573.265 l S
BT /F1 11.2 Tf 64 565.265 Td (1. Medicine search platforms that allow users to locate medicines by name or active) Tj ET
BT /F1 11.2 Tf 64 551.265 Td ( ingredient.) Tj ET
BT /F1 11.2 Tf 64 537.265 Td (2. Pharmacy locator systems that display nearby pharmacies and, in some cases, opening) Tj ET
BT /F1 11.2 Tf 64 523.265 Td ( hours, distance, or approximate availability.) Tj ET
BT /F1 11.2 Tf 64 509.265 Td (3. Inventory management systems used by pharmacies, distributors, or health-related retail) Tj ET
BT /F1 11.2 Tf 64 495.265 Td ( networks.) Tj ET
BT /F1 11.2 Tf 64 481.265 Td (4. Digital health applications that store user profiles, location data, reminders, or) Tj ET
BT /F1 11.2 Tf 64 467.265 Td ( treatment-related information.) Tj ET
BT /F1 11.2 Tf 64 453.265 Td (5. Reservation, referral, or notification workflows that allow a user or a pharmacy to react) Tj ET
BT /F1 11.2 Tf 64 439.265 Td ( when a product is out of stock.) Tj ET
BT /F1 9 Tf 495.28 24 Td (Page 1) Tj ET
endstream
endobj
6 0 obj
<< /Length 3313 >>
stream
BT /F2 13 Tf 56 787.89 Td (Distinguishing technical features) Tj ET
0.3 w 56 767.64 m 539.28 767.64 l S
BT /F1 11.2 Tf 64 759.64 Td (- secure capture of a health-card identifier or equivalent patient-related credential;) Tj ET
BT /F1 11.2 Tf 64 745.64 Td (- verification of the captured data and association with an authenticated user profile;) Tj ET
BT /F1 11.2 Tf 64 731.64 Td (- automatic derivation of a medicine reference or therapeutic need;) Tj ET
BT /F1 11.2 Tf 64 717.64 Td (- real-time comparison against pharmacy stock data from geolocated pharmacies;) Tj ET
BT /F1 11.2 Tf 64 703.64 Td (- ranking of pharmacies according to availability, distance, and opening status;) Tj ET
BT /F1 11.2 Tf 64 689.64 Td (- automatic generation of reserve, notification, order, or referral actions when stock is) Tj ET
BT /F1 11.2 Tf 64 675.64 Td ( insufficient;) Tj ET
BT /F1 11.2 Tf 64 661.64 Td (- technical traceability of the event across the processing chain.) Tj ET
BT /F2 13 Tf 56 647.64 Td (Assessment) Tj ET
0.3 w 56 627.39 m 539.28 627.39 l S
BT /F1 11.5 Tf 56 619.39 Td (The reviewed prior-art categories appear to disclose individual parts of the technical) Tj ET
BT /F1 11.5 Tf 56 605.015 Td (problem, but not the complete integrated architecture described above in a single) Tj ET
BT /F1 11.5 Tf 56 590.64 Td (system. In particular, the combination of patient-context capture through a health) Tj ET
BT /F1 11.5 Tf 56 576.265 Td (identifier, automated therapeutic inference, geolocated stock comparison, and) Tj ET
BT /F1 11.5 Tf 56 561.89 Td (machine-generated supply actions does not appear to be disclosed as a unified technical) Tj ET
BT /F1 11.5 Tf 56 547.515 Td (chain in the documents reviewed at the project level.) Tj ET
BT /F2 13 Tf 56 531.14 Td (Suggested search focus for the European search phase) Tj ET
0.3 w 56 510.89 m 539.28 510.89 l S
BT /F1 11.2 Tf 64 502.89 Td (- patient identifier scanning;) Tj ET
BT /F1 11.2 Tf 64 488.89 Td (- health-card reading or optical capture of patient credentials;) Tj ET
BT /F1 11.2 Tf 64 474.89 Td (- medicine search and pharmacy availability systems;) Tj ET
BT /F1 11.2 Tf 64 460.89 Td (- pharmacy reservation and referral workflows;) Tj ET
BT /F1 11.2 Tf 64 446.89 Td (- location-based ranking of pharmacies;) Tj ET
BT /F1 11.2 Tf 64 432.89 Td (- automated inventory-driven notification or order generation;) Tj ET
BT /F1 11.2 Tf 64 418.89 Td (- health-related digital assistants that combine user context with stock availability.) Tj ET
BT /F2 13 Tf 56 404.89 Td (Working conclusion) Tj ET
0.3 w 56 384.64 m 539.28 384.64 l S
BT /F1 11.5 Tf 56 376.64 Td (On the basis of the current project documentation, the invention is presented as a) Tj ET
BT /F1 11.5 Tf 56 362.265 Td (computer-implemented technical solution that integrates identification, verification,) Tj ET
BT /F1 11.5 Tf 56 347.89 Td (geolocation, inventory contrast, and automated supply actions in one workflow. No) Tj ET
BT /F1 11.5 Tf 56 333.515 Td (single reference has been identified at the project level that clearly discloses the) Tj ET
BT /F1 11.5 Tf 56 319.14 Td (full combination of these features.) Tj ET
BT /F2 13 Tf 56 302.765 Td (Date) Tj ET
0.3 w 56 282.515 m 539.28 282.515 l S
BT /F1 11.5 Tf 56 274.515 Td (13 August 2026) Tj ET
BT /F1 9 Tf 495.28 24 Td (Page 2) Tj ET
endstream
endobj
7 0 obj
<< /Type /Page /Parent 4 0 R /MediaBox [0 0 595.28 841.89] /Resources << /Font << /F1 1 0 R /F2 2 0 R /F3 3 0 R >> >> /Contents 5 0 R >>
endobj
8 0 obj
<< /Type /Page /Parent 4 0 R /MediaBox [0 0 595.28 841.89] /Resources << /Font << /F1 1 0 R /F2 2 0 R /F3 3 0 R >> >> /Contents 6 0 R >>
endobj
9 0 obj
<< /Type /Catalog /Pages 4 0 R >>
endobj
xref
0 10
0000000000 65535 f
0000000009 00000 n
0000000108 00000 n
0000000206 00000 n
0000000306 00000 n
0000000369 00000 n
0000002510 00000 n
0000005875 00000 n
0000006027 00000 n
0000006179 00000 n
trailer << /Size 10 /Root 9 0 R >>
startxref
6228
%%EOF
@@ -0,0 +1,418 @@
%PDF-1.4
%âãÏÓ
1 0 obj
<</Type /Font /Subtype /Type1 /BaseFont /Times-Roman /Encoding /WinAnsiEncoding >>
endobj
2 0 obj
<</Type /Font /Subtype /Type1 /BaseFont /Times-Bold /Encoding /WinAnsiEncoding >>
endobj
3 0 obj
<</Type /Font /Subtype /Type1 /BaseFont /Times-Italic /Encoding /WinAnsiEncoding >>
endobj
4 0 obj
<</Length 1104 >> stream
BT /F2 18 Tf 141 783.89 Td (SOLICITUD DE PATENTE) Tj ET
BT /F1 12 Tf 111 754.85 Td (Oficina Española de Patentes y Marcas \(OEPM\)) Tj ET
BT /F2 12 Tf 176 725.49 Td (Título de la invención:) Tj ET
BT /F1 12 Tf 66 710.13 Td (Sistema y procedimiento implementados por ordenador para la identificación de) Tj ET
BT /F1 12 Tf 66 694.77 Td (necesidades terapéuticas mediante escaneo de tarjeta sanitaria individual,) Tj ET
BT /F1 12 Tf 66 679.41 Td (contraste con stock farmacéutico geolocalizado y generación de acciones de) Tj ET
BT /F1 12 Tf 66 664.05 Td (suministro.) Tj ET
0.8 w 56 580.6899999999999 483.28 68 re S
BT /F1 11 Tf 66 636.6899999999999 Td (Solicitante: Antoni Nuñez Romeu) Tj ET
BT /F1 11 Tf 66 622.6099999999999 Td (NIF/CIF: 45858029P) Tj ET
BT /F1 11 Tf 66 608.5299999999999 Td (Domicilio: C/ Font Vella 12, 1, Terrassa, Barcelona, 08221) Tj ET
BT /F1 11 Tf 66 594.4499999999998 Td (Inventor o inventores: Antoni Nuñez Romeu) Tj ET
BT /F3 10 Tf 124 568.3699999999998 Td (Memoria descriptiva, reivindicaciones, resumen y referencia a dibujos) Tj ET
BT /F1 9 Tf 495.28 24 Td (Pagina 1) Tj ET
endstream
endobj
7 0 obj
<</Type /Font /Subtype /Type1 /BaseFont /Times-Roman /Encoding /WinAnsiEncoding >>
endobj
8 0 obj
<</Type /Font /Subtype /Type1 /BaseFont /Times-Bold /Encoding /WinAnsiEncoding >>
endobj
9 0 obj
<</Type /Font /Subtype /Type1 /BaseFont /Times-Italic /Encoding /WinAnsiEncoding >>
endobj
11 0 obj
<</Length 4950 >> stream
BT /F2 13 Tf 56 781.89 Td (1. Campo técnico de la invención) Tj ET
0.2 w 56 761.25 m 539.28 761.25 l S
BT /F1 11.5 Tf 56 751.25 Td (La invención se encuadra en los sistemas informáticos aplicados al sector sanitario) Tj ET
BT /F1 11.5 Tf 56 736.53 Td (y farmacéutico, en particular en soluciones de captura de identificadores) Tj ET
BT /F1 11.5 Tf 56 721.81 Td (sanitarios, comparación automatizada de disponibilidad de medicamentos y asistencia) Tj ET
BT /F1 11.5 Tf 56 707.0899999999999 Td (digital a la dispensación y suministro.) Tj ET
BT /F2 13 Tf 56 688.3699999999999 Td (2. Antecedentes de la invención) Tj ET
0.2 w 56 667.7299999999999 m 539.28 667.7299999999999 l S
BT /F1 11.5 Tf 56 657.7299999999999 Td (Las soluciones actuales de búsqueda de medicamentos y farmacias permiten localizar) Tj ET
BT /F1 11.5 Tf 56 643.0099999999999 Td (productos y mostrar stock aproximado, pero normalmente operan a partir de una) Tj ET
BT /F1 11.5 Tf 56 628.2899999999998 Td (búsqueda manual por nombre de medicamento o principio activo. También existen) Tj ET
BT /F1 11.5 Tf 56 613.5699999999998 Td (sistemas de geolocalización y de gestión de inventario, pero suelen estar) Tj ET
BT /F1 11.5 Tf 56 598.8499999999998 Td (desacoplados del contexto sanitario del usuario y no integran, en una única cadena) Tj ET
BT /F1 11.5 Tf 56 584.1299999999998 Td (técnica, la identificación mediante tarjeta sanitaria individual, la inferencia de) Tj ET
BT /F1 11.5 Tf 56 569.4099999999997 Td (necesidades terapéuticas y la comparación en tiempo real con disponibilidad) Tj ET
BT /F1 11.5 Tf 56 554.6899999999997 Td (farmacéutica local.) Tj ET
BT /F1 11.5 Tf 56 537.9699999999997 Td (Además, cuando no existe stock en la farmacia más próxima, el usuario debe iniciar) Tj ET
BT /F1 11.5 Tf 56 523.2499999999997 Td (manualmente nuevos procesos de búsqueda o desplazamiento, sin que el sistema) Tj ET
BT /F1 11.5 Tf 56 508.52999999999963 Td (proponga de forma automatizada acciones técnicas de reserva, pedido o derivación) Tj ET
BT /F1 11.5 Tf 56 493.8099999999996 Td (basadas en la disponibilidad real.) Tj ET
BT /F2 13 Tf 56 475.0899999999996 Td (3. Problema técnico a resolver) Tj ET
0.2 w 56 454.4499999999996 m 539.28 454.4499999999996 l S
BT /F1 11.5 Tf 56 444.4499999999996 Td (La invención resuelve el problema técnico de reducir el tiempo y la fricción entre) Tj ET
BT /F1 11.5 Tf 56 429.72999999999956 Td (la identificación de una necesidad terapéutica y la localización de una farmacia que) Tj ET
BT /F1 11.5 Tf 56 415.00999999999954 Td (disponga del medicamento adecuado, integrando:) Tj ET
BT /F1 11.2 Tf 64 398.2899999999995 Td (" lectura segura de identificadores sanitarios,) Tj ET
BT /F1 11.2 Tf 64 383.9539999999995 Td (" obtención de referencias de medicamento asociadas,) Tj ET
BT /F1 11.2 Tf 64 369.6179999999995 Td (" comparación automatizada con inventarios de farmacias,) Tj ET
BT /F1 11.2 Tf 64 355.28199999999947 Td (" priorización por proximidad y disponibilidad,) Tj ET
BT /F1 11.2 Tf 64 340.94599999999946 Td (" generación de acciones técnicas de suministro cuando no exista stock inmediato.) Tj ET
BT /F2 13 Tf 56 324.60999999999945 Td (4. Resumen de la invención) Tj ET
0.2 w 56 303.96999999999946 m 539.28 303.96999999999946 l S
BT /F1 11.5 Tf 56 293.96999999999946 Td (La invención propone un sistema y procedimiento implementados por ordenador que) Tj ET
BT /F1 11.5 Tf 56 279.24999999999943 Td (operan sobre un terminal móvil o dispositivo de lectura. El sistema captura la) Tj ET
BT /F1 11.5 Tf 56 264.5299999999994 Td (información de una tarjeta sanitaria individual mediante escaneo, valida la) Tj ET
BT /F1 11.5 Tf 56 249.8099999999994 Td (información mediante un módulo de verificación, asocia el resultado al perfil del) Tj ET
BT /F1 11.5 Tf 56 235.0899999999994 Td (usuario autenticado y obtiene una lista de medicamentos o necesidades terapéuticas) Tj ET
BT /F1 11.5 Tf 56 220.3699999999994 Td (relevantes. Posteriormente, un motor de contraste consulta un repositorio de stock) Tj ET
BT /F1 11.5 Tf 56 205.6499999999994 Td (de farmacias geolocalizadas y determina la coincidencia entre la necesidad detectada) Tj ET
BT /F1 11.5 Tf 56 190.9299999999994 Td (y la disponibilidad real en cada establecimiento. Si existe stock, el sistema) Tj ET
BT /F1 11.5 Tf 56 176.2099999999994 Td (prioriza las farmacias adecuadas y presenta una ruta de acceso o reserva. Si no) Tj ET
BT /F1 11.5 Tf 56 161.4899999999994 Td (existe stock, el sistema genera automáticamente una acción de aviso, pedido, reserva) Tj ET
BT /F1 11.5 Tf 56 146.7699999999994 Td (o derivación, conservando trazabilidad técnica del evento. La invención no se limita) Tj ET
BT /F1 11.5 Tf 56 132.04999999999941 Td (a una búsqueda manual, sino que integra captura, validación, georreferenciación,) Tj ET
BT /F1 11.5 Tf 56 117.32999999999942 Td (consulta de stock y respuesta automática en una única arquitectura técnica.) Tj ET
BT /F1 9 Tf 495.28 24 Td (Pagina 2) Tj ET
endstream
endobj
14 0 obj
<</Type /Font /Subtype /Type1 /BaseFont /Times-Roman /Encoding /WinAnsiEncoding >>
endobj
15 0 obj
<</Type /Font /Subtype /Type1 /BaseFont /Times-Bold /Encoding /WinAnsiEncoding >>
endobj
16 0 obj
<</Type /Font /Subtype /Type1 /BaseFont /Times-Italic /Encoding /WinAnsiEncoding >>
endobj
19 0 obj
<</Length 3436 >> stream
BT /F2 13 Tf 56 781.89 Td (5. Breve descripción de los dibujos) Tj ET
0.2 w 56 761.25 m 539.28 761.25 l S
BT /F1 11.5 Tf 56 751.25 Td (Figura 1. Diagrama de bloques general del sistema.) Tj ET
BT /F1 11.5 Tf 56 734.53 Td (Figura 2. Flujo de captura y validación del escaneo de la TSI.) Tj ET
BT /F1 11.5 Tf 56 717.81 Td (Figura 3. Flujo de contraste entre necesidad terapéutica y stock de farmacias.) Tj ET
BT /F1 11.5 Tf 56 701.0899999999999 Td (Figura 4. Flujo de acción cuando no existe stock disponible.) Tj ET
BT /F2 13 Tf 56 682.3699999999999 Td (6. Descripción detallada de la invención) Tj ET
0.2 w 56 661.7299999999999 m 539.28 661.7299999999999 l S
BT /F2 11.5 Tf 56 651.7299999999999 Td (6.1 Arquitectura general) Tj ET
BT /F1 11.5 Tf 56 637.0099999999999 Td (El sistema comprende al menos los siguientes módulos:) Tj ET
BT /F1 11.2 Tf 64 620.2899999999998 Td (1. un módulo de captura de imagen o lectura óptica para escanear la tarjeta sanitaria) Tj ET
BT /F1 11.2 Tf 64 605.9539999999998 Td ( individual;) Tj ET
BT /F1 11.2 Tf 64 591.6179999999998 Td (2. un módulo de extracción de datos para identificar los campos relevantes de la tarjeta;) Tj ET
BT /F1 11.2 Tf 64 577.2819999999998 Td (3. un módulo de verificación de identidad y autorización, vinculado al usuario) Tj ET
BT /F1 11.2 Tf 64 562.9459999999998 Td ( autenticado;) Tj ET
BT /F1 11.2 Tf 64 548.6099999999998 Td (4. un módulo de obtención de necesidades terapéuticas o medicamentos asociados;) Tj ET
BT /F1 11.2 Tf 64 534.2739999999998 Td (5. un módulo de geolocalización para obtener la posición del usuario o una posición de) Tj ET
BT /F1 11.2 Tf 64 519.9379999999998 Td ( referencia;) Tj ET
BT /F1 11.2 Tf 64 505.60199999999975 Td (6. un módulo de consulta de inventario para recuperar stock, precio y horario de) Tj ET
BT /F1 11.2 Tf 64 491.26599999999974 Td ( farmacias;) Tj ET
BT /F1 11.2 Tf 64 476.9299999999997 Td (7. un módulo de contraste y priorización para ordenar farmacias según disponibilidad y) Tj ET
BT /F1 11.2 Tf 64 462.5939999999997 Td ( cercanía;) Tj ET
BT /F1 11.2 Tf 64 448.2579999999997 Td (8. un módulo de generación de acciones para crear reserva, aviso, pedido o derivación;) Tj ET
BT /F1 11.2 Tf 64 433.9219999999997 Td (9. un módulo de trazabilidad y auditoría.) Tj ET
BT /F2 11.5 Tf 56 419.5859999999997 Td (6.2 Funcionamiento) Tj ET
BT /F1 11.5 Tf 56 404.86599999999964 Td (En una realización preferida, el usuario abre la aplicación y solicita el escaneo de) Tj ET
BT /F1 11.5 Tf 56 390.1459999999996 Td (su tarjeta sanitaria individual. El terminal captura la imagen o el código de la) Tj ET
BT /F1 11.5 Tf 56 375.4259999999996 Td (tarjeta y el módulo de extracción identifica los datos técnicos necesarios para) Tj ET
BT /F1 11.5 Tf 56 360.70599999999956 Td (enlazar el contexto sanitario del usuario con un conjunto de medicamentos o) Tj ET
BT /F1 11.5 Tf 56 345.98599999999954 Td (referencias de tratamiento.) Tj ET
BT /F1 11.5 Tf 56 329.2659999999995 Td (El sistema valida que la operación se ejecuta sobre un usuario autenticado y) Tj ET
BT /F1 11.5 Tf 56 314.5459999999995 Td (autorizado. A continuación, el motor de contraste consulta el inventario de) Tj ET
BT /F1 11.5 Tf 56 299.82599999999945 Td (farmacias cercanas, recuperando para cada farmacia al menos su ubicación, horario,) Tj ET
BT /F1 11.5 Tf 56 285.1059999999994 Td (stock y, opcionalmente, precio.) Tj ET
BT /F1 9 Tf 495.28 24 Td (Pagina 3) Tj ET
endstream
endobj
22 0 obj
<</Type /Font /Subtype /Type1 /BaseFont /Times-Roman /Encoding /WinAnsiEncoding >>
endobj
23 0 obj
<</Type /Font /Subtype /Type1 /BaseFont /Times-Bold /Encoding /WinAnsiEncoding >>
endobj
24 0 obj
<</Type /Font /Subtype /Type1 /BaseFont /Times-Italic /Encoding /WinAnsiEncoding >>
endobj
28 0 obj
<</Length 3271 >> stream
BT /F2 11.5 Tf 56 783.89 Td (6.2 Funcionamiento \(continuación\)) Tj ET
BT /F1 11.5 Tf 56 769.17 Td (El módulo de priorización calcula una orden de resultados en base a criterios) Tj ET
BT /F1 11.5 Tf 56 754.4499999999999 Td (técnicos tales como existencia de stock, nivel de stock, distancia geográfica,) Tj ET
BT /F1 11.5 Tf 56 739.7299999999999 Td (horario de apertura y capacidad de reserva o solicitud de pedido.) Tj ET
BT /F1 11.5 Tf 56 723.0099999999999 Td (Cuando existe disponibilidad, el sistema muestra una lista priorizada de farmacias y) Tj ET
BT /F1 11.5 Tf 56 708.2899999999998 Td (puede iniciar una reserva o una indicación de recogida. Cuando no existe) Tj ET
BT /F1 11.5 Tf 56 693.5699999999998 Td (disponibilidad, el sistema no se limita a informar de la falta de stock, sino que) Tj ET
BT /F1 11.5 Tf 56 678.8499999999998 Td (genera una acción técnica adicional, por ejemplo alerta a la farmacia seleccionada,) Tj ET
BT /F1 11.5 Tf 56 664.1299999999998 Td (solicitud de pedido a distribución o proveedor, derivación automática a otra) Tj ET
BT /F1 11.5 Tf 56 649.4099999999997 Td (farmacia con stock o registro de una lista de espera o interés de suministro.) Tj ET
BT /F2 11.5 Tf 56 632.6899999999997 Td (6.3 Tratamiento de datos y seguridad) Tj ET
BT /F1 11.5 Tf 56 617.9699999999997 Td (La invención prevé que la información derivada del escaneo de la TSI se trate con) Tj ET
BT /F1 11.5 Tf 56 603.2499999999997 Td (medidas de minimización de datos, control de acceso, cifrado en tránsito y registro) Tj ET
BT /F1 11.5 Tf 56 588.5299999999996 Td (de eventos. El sistema no expone innecesariamente los datos de la tarjeta sanitaria) Tj ET
BT /F1 11.5 Tf 56 573.8099999999996 Td (y solo conserva los elementos imprescindibles para la operación descrita.) Tj ET
BT /F2 11.5 Tf 56 557.0899999999996 Td (6.4 Integración con la plataforma FarmaFinder) Tj ET
BT /F1 11.5 Tf 56 542.3699999999995 Td (En una implementación concreta, el sistema puede integrarse con una plataforma que) Tj ET
BT /F1 11.5 Tf 56 527.6499999999995 Td (ya disponga de catálogo de farmacias, geolocalización, stock por medicamento,) Tj ET
BT /F1 11.5 Tf 56 512.9299999999995 Td (autenticación de usuario, notificaciones push e histórico de acciones y) Tj ET
BT /F1 11.5 Tf 56 498.20999999999947 Td (preferencias.) Tj ET
BT /F1 11.5 Tf 56 481.48999999999944 Td (La integración permite que el resultado del escaneo de la TSI no se limite a una) Tj ET
BT /F1 11.5 Tf 56 466.7699999999994 Td (consulta pasiva, sino que se convierta en un disparador de decisiones técnicas sobre) Tj ET
BT /F1 11.5 Tf 56 452.0499999999994 Td (disponibilidad y suministro.) Tj ET
BT /F2 13 Tf 56 433.32999999999936 Td (7. Ejemplo de realización) Tj ET
0.2 w 56 412.6899999999994 m 539.28 412.6899999999994 l S
BT /F1 11.2 Tf 64 402.6899999999994 Td (1. El usuario inicia sesión en la aplicación.) Tj ET
BT /F1 11.2 Tf 64 388.35399999999936 Td (2. El usuario escanea su TSI con la cámara del terminal.) Tj ET
BT /F1 11.2 Tf 64 374.01799999999935 Td (3. El sistema extrae el identificador necesario y lo vincula al perfil autorizado.) Tj ET
BT /F1 11.2 Tf 64 359.68199999999933 Td (4. El motor terapéutico asocia una necesidad de medicamento o tratamiento.) Tj ET
BT /F1 9 Tf 495.28 24 Td (Pagina 4) Tj ET
endstream
endobj
31 0 obj
<</Type /Font /Subtype /Type1 /BaseFont /Times-Roman /Encoding /WinAnsiEncoding >>
endobj
32 0 obj
<</Type /Font /Subtype /Type1 /BaseFont /Times-Bold /Encoding /WinAnsiEncoding >>
endobj
33 0 obj
<</Type /Font /Subtype /Type1 /BaseFont /Times-Italic /Encoding /WinAnsiEncoding >>
endobj
38 0 obj
<</Length 3689 >> stream
BT /F2 13 Tf 56 781.89 Td (7. Ejemplo de realización \(continuación\)) Tj ET
0.2 w 56 761.25 m 539.28 761.25 l S
BT /F1 11.2 Tf 64 751.25 Td (1. El sistema consulta farmacias cercanas con stock del medicamento.) Tj ET
BT /F1 11.2 Tf 64 736.914 Td (2. Si hay stock, ordena las farmacias por proximidad y disponibilidad.) Tj ET
BT /F1 11.2 Tf 64 722.578 Td (3. Si no hay stock, genera una reserva, una alerta o una solicitud de pedido.) Tj ET
BT /F2 13 Tf 56 706.242 Td (8. Reivindicaciones) Tj ET
0.2 w 56 685.602 m 539.28 685.602 l S
BT /F1 11.2 Tf 56 675.602 Td (Reivindicación 1. Sistema implementado por ordenador para identificar una necesidad) Tj ET
BT /F1 11.2 Tf 56 661.266 Td (terapéutica a partir del escaneo de una tarjeta sanitaria individual, caracterizado) Tj ET
BT /F1 11.2 Tf 56 646.93 Td (porque comprende medios de captura para obtener datos de la tarjeta sanitaria) Tj ET
BT /F1 11.2 Tf 56 632.5939999999999 Td (individual; medios de verificación para asociar dichos datos a un usuario autenticado;) Tj ET
BT /F1 11.2 Tf 56 618.2579999999999 Td (medios de obtención de una referencia de medicamento o necesidad terapéutica; medios) Tj ET
BT /F1 11.2 Tf 56 603.9219999999999 Td (de consulta de inventario para recuperar stock de farmacias geolocalizadas; medios de) Tj ET
BT /F1 11.2 Tf 56 589.5859999999999 Td (contraste para comparar la referencia de medicamento o necesidad terapéutica con el) Tj ET
BT /F1 11.2 Tf 56 575.2499999999999 Td (stock disponible; y medios de generación de acciones para producir una reserva, aviso,) Tj ET
BT /F1 11.2 Tf 56 560.9139999999999 Td (pedido o derivación cuando el stock disponible sea insuficiente.) Tj ET
BT /F1 11.2 Tf 56 544.5779999999999 Td (Reivindicación 2. Sistema según la reivindicación 1, en el que los medios de consulta) Tj ET
BT /F1 11.2 Tf 56 530.2419999999998 Td (de inventario ordenan las farmacias según una combinación de stock disponible,) Tj ET
BT /F1 11.2 Tf 56 515.9059999999998 Td (proximidad geográfica y horario de apertura.) Tj ET
BT /F1 11.2 Tf 56 499.5699999999998 Td (Reivindicación 3. Sistema según cualquiera de las reivindicaciones anteriores, en el) Tj ET
BT /F1 11.2 Tf 56 485.2339999999998 Td (que los datos de la tarjeta sanitaria individual son tratados mediante un módulo de) Tj ET
BT /F1 11.2 Tf 56 470.8979999999998 Td (minimización y cifrado para limitar su almacenamiento a los campos técnicamente) Tj ET
BT /F1 11.2 Tf 56 456.5619999999998 Td (necesarios.) Tj ET
BT /F1 11.2 Tf 56 440.2259999999998 Td (Reivindicación 4. Procedimiento implementado por ordenador para asistir en la) Tj ET
BT /F1 11.2 Tf 56 425.88999999999976 Td (localización y suministro de medicamentos, caracterizado porque comprende escanear una) Tj ET
BT /F1 11.2 Tf 56 411.55399999999975 Td (tarjeta sanitaria individual; verificar la autorización del usuario; derivar una) Tj ET
BT /F1 11.2 Tf 56 397.21799999999973 Td (necesidad terapéutica o referencia de medicamento; consultar stock de farmacias) Tj ET
BT /F1 11.2 Tf 56 382.8819999999997 Td (geolocalizadas; comparar la necesidad con el stock disponible; y generar una acción) Tj ET
BT /F1 11.2 Tf 56 368.5459999999997 Td (técnica de reserva, aviso, pedido o derivación cuando no exista disponibilidad) Tj ET
BT /F1 11.2 Tf 56 354.2099999999997 Td (suficiente.) Tj ET
BT /F1 11.2 Tf 56 337.8739999999997 Td (Reivindicación 5. Dispositivo móvil, terminal o sistema de lectura que comprende una) Tj ET
BT /F1 11.2 Tf 56 323.53799999999967 Td (cámara o lector óptico y que está configurado para ejecutar el sistema de cualquiera) Tj ET
BT /F1 11.2 Tf 56 309.20199999999966 Td (de las reivindicaciones anteriores.) Tj ET
BT /F1 9 Tf 495.28 24 Td (Pagina 5) Tj ET
endstream
endobj
41 0 obj
<</Type /Font /Subtype /Type1 /BaseFont /Times-Roman /Encoding /WinAnsiEncoding >>
endobj
42 0 obj
<</Type /Font /Subtype /Type1 /BaseFont /Times-Bold /Encoding /WinAnsiEncoding >>
endobj
43 0 obj
<</Type /Font /Subtype /Type1 /BaseFont /Times-Italic /Encoding /WinAnsiEncoding >>
endobj
49 0 obj
<</Length 4216 >> stream
BT /F2 13 Tf 56 781.89 Td (9. Aplicación industrial) Tj ET
0.2 w 56 761.25 m 539.28 761.25 l S
BT /F1 11.5 Tf 56 751.25 Td (La invención es susceptible de aplicación industrial en plataformas de asistencia) Tj ET
BT /F1 11.5 Tf 56 736.53 Td (farmacéutica, sistemas de gestión de inventario, servicios de reserva o pedido de) Tj ET
BT /F1 11.5 Tf 56 721.81 Td (medicamentos y terminales de consulta sanitaria.) Tj ET
BT /F2 13 Tf 56 703.0899999999999 Td (10. Resumen) Tj ET
0.2 w 56 682.4499999999999 m 539.28 682.4499999999999 l S
BT /F1 11.5 Tf 56 672.4499999999999 Td (La presente invención se refiere a un sistema y procedimiento implementados por) Tj ET
BT /F1 11.5 Tf 56 657.7299999999999 Td (ordenador que permiten, a partir del escaneo de una tarjeta sanitaria individual) Tj ET
BT /F1 11.5 Tf 56 643.0099999999999 Td (mediante un terminal móvil o dispositivo equivalente, asociar de forma segura un) Tj ET
BT /F1 11.5 Tf 56 628.2899999999998 Td (contexto de paciente a una necesidad terapéutica, obtener una referencia de) Tj ET
BT /F1 11.5 Tf 56 613.5699999999998 Td (medicamentos relevantes, comparar dicha necesidad con el stock disponible en) Tj ET
BT /F1 11.5 Tf 56 598.8499999999998 Td (farmacias geolocalizadas y generar, en caso de falta de disponibilidad, una acción) Tj ET
BT /F1 11.5 Tf 56 584.1299999999998 Td (de reserva, aviso, solicitud de pedido o derivación a una farmacia alternativa. El) Tj ET
BT /F1 11.5 Tf 56 569.4099999999997 Td (sistema integra captura de datos, verificación de identidad, geolocalización,) Tj ET
BT /F1 11.5 Tf 56 554.6899999999997 Td (consulta de disponibilidad, gestión de stock y generación de eventos de suministro) Tj ET
BT /F1 11.5 Tf 56 539.9699999999997 Td (en una única cadena técnica de procesamiento.) Tj ET
BT /F2 13 Tf 56 521.2499999999997 Td (11. Anexo de dibujos) Tj ET
0.2 w 56 500.6099999999997 m 539.28 500.6099999999997 l S
BT /F1 11.5 Tf 56 490.6099999999997 Td (Las figuras indicadas en la presente memoria tienen carácter esquemático y se) Tj ET
BT /F1 11.5 Tf 56 475.88999999999965 Td (incorporan a efectos ilustrativos de la arquitectura, del flujo de captura, del) Tj ET
BT /F1 11.5 Tf 56 461.1699999999996 Td (contraste de stock y de la generación de acciones. En una presentación definitiva,) Tj ET
BT /F1 11.5 Tf 56 446.4499999999996 Td (dichas figuras podrán acompañarse como láminas separadas numeradas de forma) Tj ET
BT /F1 11.5 Tf 56 431.72999999999956 Td (consecutiva.) Tj ET
BT /F2 13 Tf 56 413.00999999999954 Td (12. Firma del solicitante) Tj ET
0.2 w 56 392.36999999999955 m 539.28 392.36999999999955 l S
BT /F1 11.5 Tf 56 382.36999999999955 Td (En Terrassa, a 22 de junio de 2026.) Tj ET
BT /F1 11.5 Tf 56 365.6499999999995 Td (Fdo.: Antoni Nuñez Romeu) Tj ET
BT /F1 11.5 Tf 56 348.9299999999995 Td (DNI/NIF: 45858029P) Tj ET
BT /F2 13 Tf 56 330.20999999999947 Td (13. Notas para presentación en OEPM) Tj ET
0.2 w 56 309.5699999999995 m 539.28 309.5699999999995 l S
BT /F1 11.2 Tf 64 299.5699999999995 Td (" La solicitud debe presentarse como una única invención o un único concepto inventivo) Tj ET
BT /F1 11.2 Tf 64 285.23399999999947 Td ( general.) Tj ET
BT /F1 11.2 Tf 64 270.89799999999946 Td (" La descripción, reivindicaciones y dibujos deben ser coherentes entre sí.) Tj ET
BT /F1 11.2 Tf 64 256.56199999999944 Td (" La protección en patente debe apoyarse en un efecto técnico concreto y no en una mera) Tj ET
BT /F1 11.2 Tf 64 242.22599999999943 Td ( regla comercial o método abstracto.) Tj ET
BT /F1 11.2 Tf 64 227.88999999999942 Td (" Esta redacción evita formular una reivindicación independiente de "programa de) Tj ET
BT /F1 11.2 Tf 64 213.5539999999994 Td ( ordenador como tal" y se centra en un sistema y un procedimiento implementados por) Tj ET
BT /F1 11.2 Tf 64 199.2179999999994 Td ( ordenador con interacción técnica sobre captura, contraste de inventario y generación) Tj ET
BT /F1 11.2 Tf 64 184.88199999999938 Td ( de acciones.) Tj ET
BT /F1 11.2 Tf 64 170.54599999999937 Td (" Antes de presentar la solicitud conviene una búsqueda de anterioridades y una revisión) Tj ET
BT /F1 11.2 Tf 64 156.20999999999935 Td ( por agente de propiedad industrial.) Tj ET
BT /F1 9 Tf 495.28 24 Td (Pagina 6) Tj ET
endstream
endobj
52 0 obj
<</Type /Font /Subtype /Type1 /BaseFont /Times-Roman /Encoding /WinAnsiEncoding >>
endobj
53 0 obj
<</Type /Font /Subtype /Type1 /BaseFont /Times-Bold /Encoding /WinAnsiEncoding >>
endobj
54 0 obj
<</Type /Font /Subtype /Type1 /BaseFont /Times-Italic /Encoding /WinAnsiEncoding >>
endobj
56 0 obj
<</Length 1123 >> stream
BT /F2 18 Tf 231 785.89 Td (Dibujos) Tj ET
BT /F3 11.5 Tf 101 763.39 Td (Bloque limpio para la presentación de la) Tj ET
BT /F3 11.5 Tf 101 749.015 Td (solicitud) Tj ET
BT /F2 13 Tf 56 716.64 Td (Breve descripción de los dibujos) Tj ET
0.3 w 56 698.39 m 539.28 698.39 l S
BT /F1 11.5 Tf 56 690.39 Td (Figura 1. Diagrama de bloques general del sistema.) Tj ET
BT /F1 11.5 Tf 56 674.015 Td (Figura 2. Flujo de captura y validación del escaneo de la TSI.) Tj ET
BT /F1 11.5 Tf 56 657.64 Td (Figura 3. Flujo de contraste entre necesidad terapéutica y stock de farmacias.) Tj ET
BT /F1 11.5 Tf 56 641.265 Td (Figura 4. Flujo de acción cuando no existe stock disponible.) Tj ET
BT /F2 13 Tf 56 612.89 Td (Nota) Tj ET
0.3 w 56 594.64 m 539.28 594.64 l S
BT /F1 11.5 Tf 56 586.64 Td (El documento fuente contiene referencias esquemáticas a las figuras, pero no) Tj ET
BT /F1 11.5 Tf 56 572.265 Td (incluye láminas técnicas dibujadas de forma separada. Si la Oficina requiere) Tj ET
BT /F1 11.5 Tf 56 557.89 Td (placas gráficas, habrá que añadirlas antes de la presentación definitiva.) Tj ET
BT /F1 9 Tf 495.28 24 Td (Page 1) Tj ET
endstream
endobj
58 0 obj
<< /Type /Catalog /Pages 59 0 R>>
endobj
59 0 obj
<< /Type /Pages /Kids [ 60 0 R 61 0 R 62 0 R 63 0 R 64 0 R 65 0 R 66 0 R ] /Count 7 >>
endobj
60 0 obj
<< /Type /Page /Parent 59 0 R /Contents 4 0 R /Resources <</Font <</F1 1 0 R /F2 2 0 R /F3 3 0 R >> >> /MediaBox [0 0 595.28 841.89 ] /TrimBox [0 0 595.28 841.89 ] /Rotate 0 >>
endobj
61 0 obj
<< /Type /Page /Parent 59 0 R /Contents 11 0 R /Resources <</Font <</F1 7 0 R /F2 8 0 R /F3 9 0 R >> >> /MediaBox [0 0 595.28 841.89 ] /TrimBox [0 0 595.28 841.89 ] /Rotate 0 >>
endobj
62 0 obj
<< /Type /Page /Parent 59 0 R /Contents 19 0 R /Resources <</Font <</F1 14 0 R /F2 15 0 R /F3 16 0 R >> >> /MediaBox [0 0 595.28 841.89 ] /TrimBox [0 0 595.28 841.89 ] /Rotate 0 >>
endobj
63 0 obj
<< /Type /Page /Parent 59 0 R /Contents 28 0 R /Resources <</Font <</F1 22 0 R /F2 23 0 R /F3 24 0 R >> >> /MediaBox [0 0 595.28 841.89 ] /TrimBox [0 0 595.28 841.89 ] /Rotate 0 >>
endobj
64 0 obj
<< /Type /Page /Parent 59 0 R /Contents 38 0 R /Resources <</Font <</F1 31 0 R /F2 32 0 R /F3 33 0 R >> >> /MediaBox [0 0 595.28 841.89 ] /TrimBox [0 0 595.28 841.89 ] /Rotate 0 >>
endobj
65 0 obj
<< /Type /Page /Parent 59 0 R /Contents 49 0 R /Resources <</Font <</F1 41 0 R /F2 42 0 R /F3 43 0 R >> >> /MediaBox [0 0 595.28 841.89 ] /TrimBox [0 0 595.28 841.89 ] /Rotate 0 >>
endobj
66 0 obj
<< /Type /Page /Parent 59 0 R /Contents 56 0 R /Resources <</Font <</F1 52 0 R /F2 53 0 R /F3 54 0 R >> >> /MediaBox [0 0 595.28 841.89 ] /TrimBox [0 0 595.28 841.89 ] /Rotate 0 >>
endobj
xref
0 67
0000000005 65535 f
0000000015 00000 n
0000000117 00000 n
0000000218 00000 n
0000000321 00000 n
0000000006 00000 f
0000000010 00000 f
0000001483 00000 n
0000001585 00000 n
0000001686 00000 n
0000000012 00000 f
0000001789 00000 n
0000000013 00000 f
0000000017 00000 f
0000006798 00000 n
0000006901 00000 n
0000007003 00000 n
0000000018 00000 f
0000000020 00000 f
0000007107 00000 n
0000000021 00000 f
0000000025 00000 f
0000010602 00000 n
0000010705 00000 n
0000010807 00000 n
0000000026 00000 f
0000000027 00000 f
0000000029 00000 f
0000010911 00000 n
0000000030 00000 f
0000000034 00000 f
0000014241 00000 n
0000014344 00000 n
0000014446 00000 n
0000000035 00000 f
0000000036 00000 f
0000000037 00000 f
0000000039 00000 f
0000014550 00000 n
0000000040 00000 f
0000000044 00000 f
0000018298 00000 n
0000018401 00000 n
0000018503 00000 n
0000000045 00000 f
0000000046 00000 f
0000000047 00000 f
0000000048 00000 f
0000000050 00000 f
0000018607 00000 n
0000000051 00000 f
0000000055 00000 f
0000022882 00000 n
0000022985 00000 n
0000023087 00000 n
0000000057 00000 f
0000023191 00000 n
0000000000 00000 f
0000024373 00000 n
0000024423 00000 n
0000024526 00000 n
0000024725 00000 n
0000024925 00000 n
0000025128 00000 n
0000025331 00000 n
0000025534 00000 n
0000025737 00000 n
trailer
<</Size 37 /ID [(›Omm–XÌ#CÀ2O{ø) (›Omm–XÌ#CÀ2O{ø) ] /Root 58 0 R >>
startxref
25940
%%EOF
@@ -0,0 +1,380 @@
%PDF-1.4
%âãÏÓ
1 0 obj
<</Type /Font /Subtype /Type1 /BaseFont /Times-Roman /Encoding /WinAnsiEncoding >>
endobj
2 0 obj
<</Type /Font /Subtype /Type1 /BaseFont /Times-Bold /Encoding /WinAnsiEncoding >>
endobj
3 0 obj
<</Type /Font /Subtype /Type1 /BaseFont /Times-Italic /Encoding /WinAnsiEncoding >>
endobj
5 0 obj
<</Length 4950 >> stream
BT /F2 13 Tf 56 781.89 Td (1. Campo técnico de la invención) Tj ET
0.2 w 56 761.25 m 539.28 761.25 l S
BT /F1 11.5 Tf 56 751.25 Td (La invención se encuadra en los sistemas informáticos aplicados al sector sanitario) Tj ET
BT /F1 11.5 Tf 56 736.53 Td (y farmacéutico, en particular en soluciones de captura de identificadores) Tj ET
BT /F1 11.5 Tf 56 721.81 Td (sanitarios, comparación automatizada de disponibilidad de medicamentos y asistencia) Tj ET
BT /F1 11.5 Tf 56 707.0899999999999 Td (digital a la dispensación y suministro.) Tj ET
BT /F2 13 Tf 56 688.3699999999999 Td (2. Antecedentes de la invención) Tj ET
0.2 w 56 667.7299999999999 m 539.28 667.7299999999999 l S
BT /F1 11.5 Tf 56 657.7299999999999 Td (Las soluciones actuales de búsqueda de medicamentos y farmacias permiten localizar) Tj ET
BT /F1 11.5 Tf 56 643.0099999999999 Td (productos y mostrar stock aproximado, pero normalmente operan a partir de una) Tj ET
BT /F1 11.5 Tf 56 628.2899999999998 Td (búsqueda manual por nombre de medicamento o principio activo. También existen) Tj ET
BT /F1 11.5 Tf 56 613.5699999999998 Td (sistemas de geolocalización y de gestión de inventario, pero suelen estar) Tj ET
BT /F1 11.5 Tf 56 598.8499999999998 Td (desacoplados del contexto sanitario del usuario y no integran, en una única cadena) Tj ET
BT /F1 11.5 Tf 56 584.1299999999998 Td (técnica, la identificación mediante tarjeta sanitaria individual, la inferencia de) Tj ET
BT /F1 11.5 Tf 56 569.4099999999997 Td (necesidades terapéuticas y la comparación en tiempo real con disponibilidad) Tj ET
BT /F1 11.5 Tf 56 554.6899999999997 Td (farmacéutica local.) Tj ET
BT /F1 11.5 Tf 56 537.9699999999997 Td (Además, cuando no existe stock en la farmacia más próxima, el usuario debe iniciar) Tj ET
BT /F1 11.5 Tf 56 523.2499999999997 Td (manualmente nuevos procesos de búsqueda o desplazamiento, sin que el sistema) Tj ET
BT /F1 11.5 Tf 56 508.52999999999963 Td (proponga de forma automatizada acciones técnicas de reserva, pedido o derivación) Tj ET
BT /F1 11.5 Tf 56 493.8099999999996 Td (basadas en la disponibilidad real.) Tj ET
BT /F2 13 Tf 56 475.0899999999996 Td (3. Problema técnico a resolver) Tj ET
0.2 w 56 454.4499999999996 m 539.28 454.4499999999996 l S
BT /F1 11.5 Tf 56 444.4499999999996 Td (La invención resuelve el problema técnico de reducir el tiempo y la fricción entre) Tj ET
BT /F1 11.5 Tf 56 429.72999999999956 Td (la identificación de una necesidad terapéutica y la localización de una farmacia que) Tj ET
BT /F1 11.5 Tf 56 415.00999999999954 Td (disponga del medicamento adecuado, integrando:) Tj ET
BT /F1 11.2 Tf 64 398.2899999999995 Td (" lectura segura de identificadores sanitarios,) Tj ET
BT /F1 11.2 Tf 64 383.9539999999995 Td (" obtención de referencias de medicamento asociadas,) Tj ET
BT /F1 11.2 Tf 64 369.6179999999995 Td (" comparación automatizada con inventarios de farmacias,) Tj ET
BT /F1 11.2 Tf 64 355.28199999999947 Td (" priorización por proximidad y disponibilidad,) Tj ET
BT /F1 11.2 Tf 64 340.94599999999946 Td (" generación de acciones técnicas de suministro cuando no exista stock inmediato.) Tj ET
BT /F2 13 Tf 56 324.60999999999945 Td (4. Resumen de la invención) Tj ET
0.2 w 56 303.96999999999946 m 539.28 303.96999999999946 l S
BT /F1 11.5 Tf 56 293.96999999999946 Td (La invención propone un sistema y procedimiento implementados por ordenador que) Tj ET
BT /F1 11.5 Tf 56 279.24999999999943 Td (operan sobre un terminal móvil o dispositivo de lectura. El sistema captura la) Tj ET
BT /F1 11.5 Tf 56 264.5299999999994 Td (información de una tarjeta sanitaria individual mediante escaneo, valida la) Tj ET
BT /F1 11.5 Tf 56 249.8099999999994 Td (información mediante un módulo de verificación, asocia el resultado al perfil del) Tj ET
BT /F1 11.5 Tf 56 235.0899999999994 Td (usuario autenticado y obtiene una lista de medicamentos o necesidades terapéuticas) Tj ET
BT /F1 11.5 Tf 56 220.3699999999994 Td (relevantes. Posteriormente, un motor de contraste consulta un repositorio de stock) Tj ET
BT /F1 11.5 Tf 56 205.6499999999994 Td (de farmacias geolocalizadas y determina la coincidencia entre la necesidad detectada) Tj ET
BT /F1 11.5 Tf 56 190.9299999999994 Td (y la disponibilidad real en cada establecimiento. Si existe stock, el sistema) Tj ET
BT /F1 11.5 Tf 56 176.2099999999994 Td (prioriza las farmacias adecuadas y presenta una ruta de acceso o reserva. Si no) Tj ET
BT /F1 11.5 Tf 56 161.4899999999994 Td (existe stock, el sistema genera automáticamente una acción de aviso, pedido, reserva) Tj ET
BT /F1 11.5 Tf 56 146.7699999999994 Td (o derivación, conservando trazabilidad técnica del evento. La invención no se limita) Tj ET
BT /F1 11.5 Tf 56 132.04999999999941 Td (a una búsqueda manual, sino que integra captura, validación, georreferenciación,) Tj ET
BT /F1 11.5 Tf 56 117.32999999999942 Td (consulta de stock y respuesta automática en una única arquitectura técnica.) Tj ET
BT /F1 9 Tf 495.28 24 Td (Pagina 2) Tj ET
endstream
endobj
8 0 obj
<</Type /Font /Subtype /Type1 /BaseFont /Times-Roman /Encoding /WinAnsiEncoding >>
endobj
9 0 obj
<</Type /Font /Subtype /Type1 /BaseFont /Times-Bold /Encoding /WinAnsiEncoding >>
endobj
10 0 obj
<</Type /Font /Subtype /Type1 /BaseFont /Times-Italic /Encoding /WinAnsiEncoding >>
endobj
13 0 obj
<</Length 3436 >> stream
BT /F2 13 Tf 56 781.89 Td (5. Breve descripción de los dibujos) Tj ET
0.2 w 56 761.25 m 539.28 761.25 l S
BT /F1 11.5 Tf 56 751.25 Td (Figura 1. Diagrama de bloques general del sistema.) Tj ET
BT /F1 11.5 Tf 56 734.53 Td (Figura 2. Flujo de captura y validación del escaneo de la TSI.) Tj ET
BT /F1 11.5 Tf 56 717.81 Td (Figura 3. Flujo de contraste entre necesidad terapéutica y stock de farmacias.) Tj ET
BT /F1 11.5 Tf 56 701.0899999999999 Td (Figura 4. Flujo de acción cuando no existe stock disponible.) Tj ET
BT /F2 13 Tf 56 682.3699999999999 Td (6. Descripción detallada de la invención) Tj ET
0.2 w 56 661.7299999999999 m 539.28 661.7299999999999 l S
BT /F2 11.5 Tf 56 651.7299999999999 Td (6.1 Arquitectura general) Tj ET
BT /F1 11.5 Tf 56 637.0099999999999 Td (El sistema comprende al menos los siguientes módulos:) Tj ET
BT /F1 11.2 Tf 64 620.2899999999998 Td (1. un módulo de captura de imagen o lectura óptica para escanear la tarjeta sanitaria) Tj ET
BT /F1 11.2 Tf 64 605.9539999999998 Td ( individual;) Tj ET
BT /F1 11.2 Tf 64 591.6179999999998 Td (2. un módulo de extracción de datos para identificar los campos relevantes de la tarjeta;) Tj ET
BT /F1 11.2 Tf 64 577.2819999999998 Td (3. un módulo de verificación de identidad y autorización, vinculado al usuario) Tj ET
BT /F1 11.2 Tf 64 562.9459999999998 Td ( autenticado;) Tj ET
BT /F1 11.2 Tf 64 548.6099999999998 Td (4. un módulo de obtención de necesidades terapéuticas o medicamentos asociados;) Tj ET
BT /F1 11.2 Tf 64 534.2739999999998 Td (5. un módulo de geolocalización para obtener la posición del usuario o una posición de) Tj ET
BT /F1 11.2 Tf 64 519.9379999999998 Td ( referencia;) Tj ET
BT /F1 11.2 Tf 64 505.60199999999975 Td (6. un módulo de consulta de inventario para recuperar stock, precio y horario de) Tj ET
BT /F1 11.2 Tf 64 491.26599999999974 Td ( farmacias;) Tj ET
BT /F1 11.2 Tf 64 476.9299999999997 Td (7. un módulo de contraste y priorización para ordenar farmacias según disponibilidad y) Tj ET
BT /F1 11.2 Tf 64 462.5939999999997 Td ( cercanía;) Tj ET
BT /F1 11.2 Tf 64 448.2579999999997 Td (8. un módulo de generación de acciones para crear reserva, aviso, pedido o derivación;) Tj ET
BT /F1 11.2 Tf 64 433.9219999999997 Td (9. un módulo de trazabilidad y auditoría.) Tj ET
BT /F2 11.5 Tf 56 419.5859999999997 Td (6.2 Funcionamiento) Tj ET
BT /F1 11.5 Tf 56 404.86599999999964 Td (En una realización preferida, el usuario abre la aplicación y solicita el escaneo de) Tj ET
BT /F1 11.5 Tf 56 390.1459999999996 Td (su tarjeta sanitaria individual. El terminal captura la imagen o el código de la) Tj ET
BT /F1 11.5 Tf 56 375.4259999999996 Td (tarjeta y el módulo de extracción identifica los datos técnicos necesarios para) Tj ET
BT /F1 11.5 Tf 56 360.70599999999956 Td (enlazar el contexto sanitario del usuario con un conjunto de medicamentos o) Tj ET
BT /F1 11.5 Tf 56 345.98599999999954 Td (referencias de tratamiento.) Tj ET
BT /F1 11.5 Tf 56 329.2659999999995 Td (El sistema valida que la operación se ejecuta sobre un usuario autenticado y) Tj ET
BT /F1 11.5 Tf 56 314.5459999999995 Td (autorizado. A continuación, el motor de contraste consulta el inventario de) Tj ET
BT /F1 11.5 Tf 56 299.82599999999945 Td (farmacias cercanas, recuperando para cada farmacia al menos su ubicación, horario,) Tj ET
BT /F1 11.5 Tf 56 285.1059999999994 Td (stock y, opcionalmente, precio.) Tj ET
BT /F1 9 Tf 495.28 24 Td (Pagina 3) Tj ET
endstream
endobj
16 0 obj
<</Type /Font /Subtype /Type1 /BaseFont /Times-Roman /Encoding /WinAnsiEncoding >>
endobj
17 0 obj
<</Type /Font /Subtype /Type1 /BaseFont /Times-Bold /Encoding /WinAnsiEncoding >>
endobj
18 0 obj
<</Type /Font /Subtype /Type1 /BaseFont /Times-Italic /Encoding /WinAnsiEncoding >>
endobj
22 0 obj
<</Length 3271 >> stream
BT /F2 11.5 Tf 56 783.89 Td (6.2 Funcionamiento \(continuación\)) Tj ET
BT /F1 11.5 Tf 56 769.17 Td (El módulo de priorización calcula una orden de resultados en base a criterios) Tj ET
BT /F1 11.5 Tf 56 754.4499999999999 Td (técnicos tales como existencia de stock, nivel de stock, distancia geográfica,) Tj ET
BT /F1 11.5 Tf 56 739.7299999999999 Td (horario de apertura y capacidad de reserva o solicitud de pedido.) Tj ET
BT /F1 11.5 Tf 56 723.0099999999999 Td (Cuando existe disponibilidad, el sistema muestra una lista priorizada de farmacias y) Tj ET
BT /F1 11.5 Tf 56 708.2899999999998 Td (puede iniciar una reserva o una indicación de recogida. Cuando no existe) Tj ET
BT /F1 11.5 Tf 56 693.5699999999998 Td (disponibilidad, el sistema no se limita a informar de la falta de stock, sino que) Tj ET
BT /F1 11.5 Tf 56 678.8499999999998 Td (genera una acción técnica adicional, por ejemplo alerta a la farmacia seleccionada,) Tj ET
BT /F1 11.5 Tf 56 664.1299999999998 Td (solicitud de pedido a distribución o proveedor, derivación automática a otra) Tj ET
BT /F1 11.5 Tf 56 649.4099999999997 Td (farmacia con stock o registro de una lista de espera o interés de suministro.) Tj ET
BT /F2 11.5 Tf 56 632.6899999999997 Td (6.3 Tratamiento de datos y seguridad) Tj ET
BT /F1 11.5 Tf 56 617.9699999999997 Td (La invención prevé que la información derivada del escaneo de la TSI se trate con) Tj ET
BT /F1 11.5 Tf 56 603.2499999999997 Td (medidas de minimización de datos, control de acceso, cifrado en tránsito y registro) Tj ET
BT /F1 11.5 Tf 56 588.5299999999996 Td (de eventos. El sistema no expone innecesariamente los datos de la tarjeta sanitaria) Tj ET
BT /F1 11.5 Tf 56 573.8099999999996 Td (y solo conserva los elementos imprescindibles para la operación descrita.) Tj ET
BT /F2 11.5 Tf 56 557.0899999999996 Td (6.4 Integración con la plataforma FarmaFinder) Tj ET
BT /F1 11.5 Tf 56 542.3699999999995 Td (En una implementación concreta, el sistema puede integrarse con una plataforma que) Tj ET
BT /F1 11.5 Tf 56 527.6499999999995 Td (ya disponga de catálogo de farmacias, geolocalización, stock por medicamento,) Tj ET
BT /F1 11.5 Tf 56 512.9299999999995 Td (autenticación de usuario, notificaciones push e histórico de acciones y) Tj ET
BT /F1 11.5 Tf 56 498.20999999999947 Td (preferencias.) Tj ET
BT /F1 11.5 Tf 56 481.48999999999944 Td (La integración permite que el resultado del escaneo de la TSI no se limite a una) Tj ET
BT /F1 11.5 Tf 56 466.7699999999994 Td (consulta pasiva, sino que se convierta en un disparador de decisiones técnicas sobre) Tj ET
BT /F1 11.5 Tf 56 452.0499999999994 Td (disponibilidad y suministro.) Tj ET
BT /F2 13 Tf 56 433.32999999999936 Td (7. Ejemplo de realización) Tj ET
0.2 w 56 412.6899999999994 m 539.28 412.6899999999994 l S
BT /F1 11.2 Tf 64 402.6899999999994 Td (1. El usuario inicia sesión en la aplicación.) Tj ET
BT /F1 11.2 Tf 64 388.35399999999936 Td (2. El usuario escanea su TSI con la cámara del terminal.) Tj ET
BT /F1 11.2 Tf 64 374.01799999999935 Td (3. El sistema extrae el identificador necesario y lo vincula al perfil autorizado.) Tj ET
BT /F1 11.2 Tf 64 359.68199999999933 Td (4. El motor terapéutico asocia una necesidad de medicamento o tratamiento.) Tj ET
BT /F1 9 Tf 495.28 24 Td (Pagina 4) Tj ET
endstream
endobj
25 0 obj
<</Type /Font /Subtype /Type1 /BaseFont /Times-Roman /Encoding /WinAnsiEncoding >>
endobj
26 0 obj
<</Type /Font /Subtype /Type1 /BaseFont /Times-Bold /Encoding /WinAnsiEncoding >>
endobj
27 0 obj
<</Type /Font /Subtype /Type1 /BaseFont /Times-Italic /Encoding /WinAnsiEncoding >>
endobj
32 0 obj
<</Length 3689 >> stream
BT /F2 13 Tf 56 781.89 Td (7. Ejemplo de realización \(continuación\)) Tj ET
0.2 w 56 761.25 m 539.28 761.25 l S
BT /F1 11.2 Tf 64 751.25 Td (1. El sistema consulta farmacias cercanas con stock del medicamento.) Tj ET
BT /F1 11.2 Tf 64 736.914 Td (2. Si hay stock, ordena las farmacias por proximidad y disponibilidad.) Tj ET
BT /F1 11.2 Tf 64 722.578 Td (3. Si no hay stock, genera una reserva, una alerta o una solicitud de pedido.) Tj ET
BT /F2 13 Tf 56 706.242 Td (8. Reivindicaciones) Tj ET
0.2 w 56 685.602 m 539.28 685.602 l S
BT /F1 11.2 Tf 56 675.602 Td (Reivindicación 1. Sistema implementado por ordenador para identificar una necesidad) Tj ET
BT /F1 11.2 Tf 56 661.266 Td (terapéutica a partir del escaneo de una tarjeta sanitaria individual, caracterizado) Tj ET
BT /F1 11.2 Tf 56 646.93 Td (porque comprende medios de captura para obtener datos de la tarjeta sanitaria) Tj ET
BT /F1 11.2 Tf 56 632.5939999999999 Td (individual; medios de verificación para asociar dichos datos a un usuario autenticado;) Tj ET
BT /F1 11.2 Tf 56 618.2579999999999 Td (medios de obtención de una referencia de medicamento o necesidad terapéutica; medios) Tj ET
BT /F1 11.2 Tf 56 603.9219999999999 Td (de consulta de inventario para recuperar stock de farmacias geolocalizadas; medios de) Tj ET
BT /F1 11.2 Tf 56 589.5859999999999 Td (contraste para comparar la referencia de medicamento o necesidad terapéutica con el) Tj ET
BT /F1 11.2 Tf 56 575.2499999999999 Td (stock disponible; y medios de generación de acciones para producir una reserva, aviso,) Tj ET
BT /F1 11.2 Tf 56 560.9139999999999 Td (pedido o derivación cuando el stock disponible sea insuficiente.) Tj ET
BT /F1 11.2 Tf 56 544.5779999999999 Td (Reivindicación 2. Sistema según la reivindicación 1, en el que los medios de consulta) Tj ET
BT /F1 11.2 Tf 56 530.2419999999998 Td (de inventario ordenan las farmacias según una combinación de stock disponible,) Tj ET
BT /F1 11.2 Tf 56 515.9059999999998 Td (proximidad geográfica y horario de apertura.) Tj ET
BT /F1 11.2 Tf 56 499.5699999999998 Td (Reivindicación 3. Sistema según cualquiera de las reivindicaciones anteriores, en el) Tj ET
BT /F1 11.2 Tf 56 485.2339999999998 Td (que los datos de la tarjeta sanitaria individual son tratados mediante un módulo de) Tj ET
BT /F1 11.2 Tf 56 470.8979999999998 Td (minimización y cifrado para limitar su almacenamiento a los campos técnicamente) Tj ET
BT /F1 11.2 Tf 56 456.5619999999998 Td (necesarios.) Tj ET
BT /F1 11.2 Tf 56 440.2259999999998 Td (Reivindicación 4. Procedimiento implementado por ordenador para asistir en la) Tj ET
BT /F1 11.2 Tf 56 425.88999999999976 Td (localización y suministro de medicamentos, caracterizado porque comprende escanear una) Tj ET
BT /F1 11.2 Tf 56 411.55399999999975 Td (tarjeta sanitaria individual; verificar la autorización del usuario; derivar una) Tj ET
BT /F1 11.2 Tf 56 397.21799999999973 Td (necesidad terapéutica o referencia de medicamento; consultar stock de farmacias) Tj ET
BT /F1 11.2 Tf 56 382.8819999999997 Td (geolocalizadas; comparar la necesidad con el stock disponible; y generar una acción) Tj ET
BT /F1 11.2 Tf 56 368.5459999999997 Td (técnica de reserva, aviso, pedido o derivación cuando no exista disponibilidad) Tj ET
BT /F1 11.2 Tf 56 354.2099999999997 Td (suficiente.) Tj ET
BT /F1 11.2 Tf 56 337.8739999999997 Td (Reivindicación 5. Dispositivo móvil, terminal o sistema de lectura que comprende una) Tj ET
BT /F1 11.2 Tf 56 323.53799999999967 Td (cámara o lector óptico y que está configurado para ejecutar el sistema de cualquiera) Tj ET
BT /F1 11.2 Tf 56 309.20199999999966 Td (de las reivindicaciones anteriores.) Tj ET
BT /F1 9 Tf 495.28 24 Td (Pagina 5) Tj ET
endstream
endobj
35 0 obj
<</Type /Font /Subtype /Type1 /BaseFont /Times-Roman /Encoding /WinAnsiEncoding >>
endobj
36 0 obj
<</Type /Font /Subtype /Type1 /BaseFont /Times-Bold /Encoding /WinAnsiEncoding >>
endobj
37 0 obj
<</Type /Font /Subtype /Type1 /BaseFont /Times-Italic /Encoding /WinAnsiEncoding >>
endobj
43 0 obj
<</Length 4216 >> stream
BT /F2 13 Tf 56 781.89 Td (9. Aplicación industrial) Tj ET
0.2 w 56 761.25 m 539.28 761.25 l S
BT /F1 11.5 Tf 56 751.25 Td (La invención es susceptible de aplicación industrial en plataformas de asistencia) Tj ET
BT /F1 11.5 Tf 56 736.53 Td (farmacéutica, sistemas de gestión de inventario, servicios de reserva o pedido de) Tj ET
BT /F1 11.5 Tf 56 721.81 Td (medicamentos y terminales de consulta sanitaria.) Tj ET
BT /F2 13 Tf 56 703.0899999999999 Td (10. Resumen) Tj ET
0.2 w 56 682.4499999999999 m 539.28 682.4499999999999 l S
BT /F1 11.5 Tf 56 672.4499999999999 Td (La presente invención se refiere a un sistema y procedimiento implementados por) Tj ET
BT /F1 11.5 Tf 56 657.7299999999999 Td (ordenador que permiten, a partir del escaneo de una tarjeta sanitaria individual) Tj ET
BT /F1 11.5 Tf 56 643.0099999999999 Td (mediante un terminal móvil o dispositivo equivalente, asociar de forma segura un) Tj ET
BT /F1 11.5 Tf 56 628.2899999999998 Td (contexto de paciente a una necesidad terapéutica, obtener una referencia de) Tj ET
BT /F1 11.5 Tf 56 613.5699999999998 Td (medicamentos relevantes, comparar dicha necesidad con el stock disponible en) Tj ET
BT /F1 11.5 Tf 56 598.8499999999998 Td (farmacias geolocalizadas y generar, en caso de falta de disponibilidad, una acción) Tj ET
BT /F1 11.5 Tf 56 584.1299999999998 Td (de reserva, aviso, solicitud de pedido o derivación a una farmacia alternativa. El) Tj ET
BT /F1 11.5 Tf 56 569.4099999999997 Td (sistema integra captura de datos, verificación de identidad, geolocalización,) Tj ET
BT /F1 11.5 Tf 56 554.6899999999997 Td (consulta de disponibilidad, gestión de stock y generación de eventos de suministro) Tj ET
BT /F1 11.5 Tf 56 539.9699999999997 Td (en una única cadena técnica de procesamiento.) Tj ET
BT /F2 13 Tf 56 521.2499999999997 Td (11. Anexo de dibujos) Tj ET
0.2 w 56 500.6099999999997 m 539.28 500.6099999999997 l S
BT /F1 11.5 Tf 56 490.6099999999997 Td (Las figuras indicadas en la presente memoria tienen carácter esquemático y se) Tj ET
BT /F1 11.5 Tf 56 475.88999999999965 Td (incorporan a efectos ilustrativos de la arquitectura, del flujo de captura, del) Tj ET
BT /F1 11.5 Tf 56 461.1699999999996 Td (contraste de stock y de la generación de acciones. En una presentación definitiva,) Tj ET
BT /F1 11.5 Tf 56 446.4499999999996 Td (dichas figuras podrán acompañarse como láminas separadas numeradas de forma) Tj ET
BT /F1 11.5 Tf 56 431.72999999999956 Td (consecutiva.) Tj ET
BT /F2 13 Tf 56 413.00999999999954 Td (12. Firma del solicitante) Tj ET
0.2 w 56 392.36999999999955 m 539.28 392.36999999999955 l S
BT /F1 11.5 Tf 56 382.36999999999955 Td (En Terrassa, a 22 de junio de 2026.) Tj ET
BT /F1 11.5 Tf 56 365.6499999999995 Td (Fdo.: Antoni Nuñez Romeu) Tj ET
BT /F1 11.5 Tf 56 348.9299999999995 Td (DNI/NIF: 45858029P) Tj ET
BT /F2 13 Tf 56 330.20999999999947 Td (13. Notas para presentación en OEPM) Tj ET
0.2 w 56 309.5699999999995 m 539.28 309.5699999999995 l S
BT /F1 11.2 Tf 64 299.5699999999995 Td (" La solicitud debe presentarse como una única invención o un único concepto inventivo) Tj ET
BT /F1 11.2 Tf 64 285.23399999999947 Td ( general.) Tj ET
BT /F1 11.2 Tf 64 270.89799999999946 Td (" La descripción, reivindicaciones y dibujos deben ser coherentes entre sí.) Tj ET
BT /F1 11.2 Tf 64 256.56199999999944 Td (" La protección en patente debe apoyarse en un efecto técnico concreto y no en una mera) Tj ET
BT /F1 11.2 Tf 64 242.22599999999943 Td ( regla comercial o método abstracto.) Tj ET
BT /F1 11.2 Tf 64 227.88999999999942 Td (" Esta redacción evita formular una reivindicación independiente de "programa de) Tj ET
BT /F1 11.2 Tf 64 213.5539999999994 Td ( ordenador como tal" y se centra en un sistema y un procedimiento implementados por) Tj ET
BT /F1 11.2 Tf 64 199.2179999999994 Td ( ordenador con interacción técnica sobre captura, contraste de inventario y generación) Tj ET
BT /F1 11.2 Tf 64 184.88199999999938 Td ( de acciones.) Tj ET
BT /F1 11.2 Tf 64 170.54599999999937 Td (" Antes de presentar la solicitud conviene una búsqueda de anterioridades y una revisión) Tj ET
BT /F1 11.2 Tf 64 156.20999999999935 Td ( por agente de propiedad industrial.) Tj ET
BT /F1 9 Tf 495.28 24 Td (Pagina 6) Tj ET
endstream
endobj
46 0 obj
<</Type /Font /Subtype /Type1 /BaseFont /Times-Roman /Encoding /WinAnsiEncoding >>
endobj
47 0 obj
<</Type /Font /Subtype /Type1 /BaseFont /Times-Bold /Encoding /WinAnsiEncoding >>
endobj
48 0 obj
<</Type /Font /Subtype /Type1 /BaseFont /Times-Italic /Encoding /WinAnsiEncoding >>
endobj
50 0 obj
<</Length 1123 >> stream
BT /F2 18 Tf 231 785.89 Td (Dibujos) Tj ET
BT /F3 11.5 Tf 101 763.39 Td (Bloque limpio para la presentación de la) Tj ET
BT /F3 11.5 Tf 101 749.015 Td (solicitud) Tj ET
BT /F2 13 Tf 56 716.64 Td (Breve descripción de los dibujos) Tj ET
0.3 w 56 698.39 m 539.28 698.39 l S
BT /F1 11.5 Tf 56 690.39 Td (Figura 1. Diagrama de bloques general del sistema.) Tj ET
BT /F1 11.5 Tf 56 674.015 Td (Figura 2. Flujo de captura y validación del escaneo de la TSI.) Tj ET
BT /F1 11.5 Tf 56 657.64 Td (Figura 3. Flujo de contraste entre necesidad terapéutica y stock de farmacias.) Tj ET
BT /F1 11.5 Tf 56 641.265 Td (Figura 4. Flujo de acción cuando no existe stock disponible.) Tj ET
BT /F2 13 Tf 56 612.89 Td (Nota) Tj ET
0.3 w 56 594.64 m 539.28 594.64 l S
BT /F1 11.5 Tf 56 586.64 Td (El documento fuente contiene referencias esquemáticas a las figuras, pero no) Tj ET
BT /F1 11.5 Tf 56 572.265 Td (incluye láminas técnicas dibujadas de forma separada. Si la Oficina requiere) Tj ET
BT /F1 11.5 Tf 56 557.89 Td (placas gráficas, habrá que añadirlas antes de la presentación definitiva.) Tj ET
BT /F1 9 Tf 495.28 24 Td (Page 1) Tj ET
endstream
endobj
52 0 obj
<< /Type /Catalog /Pages 53 0 R>>
endobj
53 0 obj
<< /Type /Pages /Kids [ 54 0 R 55 0 R 56 0 R 57 0 R 58 0 R 59 0 R ] /Count 6 >>
endobj
54 0 obj
<< /Type /Page /Parent 53 0 R /Contents 5 0 R /Resources <</Font <</F1 1 0 R /F2 2 0 R /F3 3 0 R >> >> /MediaBox [0 0 595.28 841.89 ] /TrimBox [0 0 595.28 841.89 ] /Rotate 0 >>
endobj
55 0 obj
<< /Type /Page /Parent 53 0 R /Contents 13 0 R /Resources <</Font <</F1 8 0 R /F2 9 0 R /F3 10 0 R >> >> /MediaBox [0 0 595.28 841.89 ] /TrimBox [0 0 595.28 841.89 ] /Rotate 0 >>
endobj
56 0 obj
<< /Type /Page /Parent 53 0 R /Contents 22 0 R /Resources <</Font <</F1 16 0 R /F2 17 0 R /F3 18 0 R >> >> /MediaBox [0 0 595.28 841.89 ] /TrimBox [0 0 595.28 841.89 ] /Rotate 0 >>
endobj
57 0 obj
<< /Type /Page /Parent 53 0 R /Contents 32 0 R /Resources <</Font <</F1 25 0 R /F2 26 0 R /F3 27 0 R >> >> /MediaBox [0 0 595.28 841.89 ] /TrimBox [0 0 595.28 841.89 ] /Rotate 0 >>
endobj
58 0 obj
<< /Type /Page /Parent 53 0 R /Contents 43 0 R /Resources <</Font <</F1 35 0 R /F2 36 0 R /F3 37 0 R >> >> /MediaBox [0 0 595.28 841.89 ] /TrimBox [0 0 595.28 841.89 ] /Rotate 0 >>
endobj
59 0 obj
<< /Type /Page /Parent 53 0 R /Contents 50 0 R /Resources <</Font <</F1 46 0 R /F2 47 0 R /F3 48 0 R >> >> /MediaBox [0 0 595.28 841.89 ] /TrimBox [0 0 595.28 841.89 ] /Rotate 0 >>
endobj
xref
0 60
0000000004 65535 f
0000000015 00000 n
0000000117 00000 n
0000000218 00000 n
0000000006 00000 f
0000000321 00000 n
0000000007 00000 f
0000000011 00000 f
0000005329 00000 n
0000005431 00000 n
0000005532 00000 n
0000000012 00000 f
0000000014 00000 f
0000005636 00000 n
0000000015 00000 f
0000000019 00000 f
0000009131 00000 n
0000009234 00000 n
0000009336 00000 n
0000000020 00000 f
0000000021 00000 f
0000000023 00000 f
0000009440 00000 n
0000000024 00000 f
0000000028 00000 f
0000012770 00000 n
0000012873 00000 n
0000012975 00000 n
0000000029 00000 f
0000000030 00000 f
0000000031 00000 f
0000000033 00000 f
0000013079 00000 n
0000000034 00000 f
0000000038 00000 f
0000016827 00000 n
0000016930 00000 n
0000017032 00000 n
0000000039 00000 f
0000000040 00000 f
0000000041 00000 f
0000000042 00000 f
0000000044 00000 f
0000017136 00000 n
0000000045 00000 f
0000000049 00000 f
0000021411 00000 n
0000021514 00000 n
0000021616 00000 n
0000000051 00000 f
0000021720 00000 n
0000000000 00000 f
0000022902 00000 n
0000022952 00000 n
0000023048 00000 n
0000023247 00000 n
0000023448 00000 n
0000023651 00000 n
0000023854 00000 n
0000024057 00000 n
trailer
<</Size 32 /ID [(ÍÃÔ£È\rù+ 43B X�) (ÍÃÔ£È\rù+ 43B X�) ] /Root 52 0 R >>
startxref
24260
%%EOF
@@ -0,0 +1,268 @@
%PDF-1.4
%âãÏÓ
1 0 obj
<</Type /Font /Subtype /Type1 /BaseFont /Times-Roman /Encoding /WinAnsiEncoding >>
endobj
2 0 obj
<</Type /Font /Subtype /Type1 /BaseFont /Times-Bold /Encoding /WinAnsiEncoding >>
endobj
3 0 obj
<</Type /Font /Subtype /Type1 /BaseFont /Times-Italic /Encoding /WinAnsiEncoding >>
endobj
5 0 obj
<</Length 4950 >> stream
BT /F2 13 Tf 56 781.89 Td (1. Campo técnico de la invención) Tj ET
0.2 w 56 761.25 m 539.28 761.25 l S
BT /F1 11.5 Tf 56 751.25 Td (La invención se encuadra en los sistemas informáticos aplicados al sector sanitario) Tj ET
BT /F1 11.5 Tf 56 736.53 Td (y farmacéutico, en particular en soluciones de captura de identificadores) Tj ET
BT /F1 11.5 Tf 56 721.81 Td (sanitarios, comparación automatizada de disponibilidad de medicamentos y asistencia) Tj ET
BT /F1 11.5 Tf 56 707.0899999999999 Td (digital a la dispensación y suministro.) Tj ET
BT /F2 13 Tf 56 688.3699999999999 Td (2. Antecedentes de la invención) Tj ET
0.2 w 56 667.7299999999999 m 539.28 667.7299999999999 l S
BT /F1 11.5 Tf 56 657.7299999999999 Td (Las soluciones actuales de búsqueda de medicamentos y farmacias permiten localizar) Tj ET
BT /F1 11.5 Tf 56 643.0099999999999 Td (productos y mostrar stock aproximado, pero normalmente operan a partir de una) Tj ET
BT /F1 11.5 Tf 56 628.2899999999998 Td (búsqueda manual por nombre de medicamento o principio activo. También existen) Tj ET
BT /F1 11.5 Tf 56 613.5699999999998 Td (sistemas de geolocalización y de gestión de inventario, pero suelen estar) Tj ET
BT /F1 11.5 Tf 56 598.8499999999998 Td (desacoplados del contexto sanitario del usuario y no integran, en una única cadena) Tj ET
BT /F1 11.5 Tf 56 584.1299999999998 Td (técnica, la identificación mediante tarjeta sanitaria individual, la inferencia de) Tj ET
BT /F1 11.5 Tf 56 569.4099999999997 Td (necesidades terapéuticas y la comparación en tiempo real con disponibilidad) Tj ET
BT /F1 11.5 Tf 56 554.6899999999997 Td (farmacéutica local.) Tj ET
BT /F1 11.5 Tf 56 537.9699999999997 Td (Además, cuando no existe stock en la farmacia más próxima, el usuario debe iniciar) Tj ET
BT /F1 11.5 Tf 56 523.2499999999997 Td (manualmente nuevos procesos de búsqueda o desplazamiento, sin que el sistema) Tj ET
BT /F1 11.5 Tf 56 508.52999999999963 Td (proponga de forma automatizada acciones técnicas de reserva, pedido o derivación) Tj ET
BT /F1 11.5 Tf 56 493.8099999999996 Td (basadas en la disponibilidad real.) Tj ET
BT /F2 13 Tf 56 475.0899999999996 Td (3. Problema técnico a resolver) Tj ET
0.2 w 56 454.4499999999996 m 539.28 454.4499999999996 l S
BT /F1 11.5 Tf 56 444.4499999999996 Td (La invención resuelve el problema técnico de reducir el tiempo y la fricción entre) Tj ET
BT /F1 11.5 Tf 56 429.72999999999956 Td (la identificación de una necesidad terapéutica y la localización de una farmacia que) Tj ET
BT /F1 11.5 Tf 56 415.00999999999954 Td (disponga del medicamento adecuado, integrando:) Tj ET
BT /F1 11.2 Tf 64 398.2899999999995 Td (" lectura segura de identificadores sanitarios,) Tj ET
BT /F1 11.2 Tf 64 383.9539999999995 Td (" obtención de referencias de medicamento asociadas,) Tj ET
BT /F1 11.2 Tf 64 369.6179999999995 Td (" comparación automatizada con inventarios de farmacias,) Tj ET
BT /F1 11.2 Tf 64 355.28199999999947 Td (" priorización por proximidad y disponibilidad,) Tj ET
BT /F1 11.2 Tf 64 340.94599999999946 Td (" generación de acciones técnicas de suministro cuando no exista stock inmediato.) Tj ET
BT /F2 13 Tf 56 324.60999999999945 Td (4. Resumen de la invención) Tj ET
0.2 w 56 303.96999999999946 m 539.28 303.96999999999946 l S
BT /F1 11.5 Tf 56 293.96999999999946 Td (La invención propone un sistema y procedimiento implementados por ordenador que) Tj ET
BT /F1 11.5 Tf 56 279.24999999999943 Td (operan sobre un terminal móvil o dispositivo de lectura. El sistema captura la) Tj ET
BT /F1 11.5 Tf 56 264.5299999999994 Td (información de una tarjeta sanitaria individual mediante escaneo, valida la) Tj ET
BT /F1 11.5 Tf 56 249.8099999999994 Td (información mediante un módulo de verificación, asocia el resultado al perfil del) Tj ET
BT /F1 11.5 Tf 56 235.0899999999994 Td (usuario autenticado y obtiene una lista de medicamentos o necesidades terapéuticas) Tj ET
BT /F1 11.5 Tf 56 220.3699999999994 Td (relevantes. Posteriormente, un motor de contraste consulta un repositorio de stock) Tj ET
BT /F1 11.5 Tf 56 205.6499999999994 Td (de farmacias geolocalizadas y determina la coincidencia entre la necesidad detectada) Tj ET
BT /F1 11.5 Tf 56 190.9299999999994 Td (y la disponibilidad real en cada establecimiento. Si existe stock, el sistema) Tj ET
BT /F1 11.5 Tf 56 176.2099999999994 Td (prioriza las farmacias adecuadas y presenta una ruta de acceso o reserva. Si no) Tj ET
BT /F1 11.5 Tf 56 161.4899999999994 Td (existe stock, el sistema genera automáticamente una acción de aviso, pedido, reserva) Tj ET
BT /F1 11.5 Tf 56 146.7699999999994 Td (o derivación, conservando trazabilidad técnica del evento. La invención no se limita) Tj ET
BT /F1 11.5 Tf 56 132.04999999999941 Td (a una búsqueda manual, sino que integra captura, validación, georreferenciación,) Tj ET
BT /F1 11.5 Tf 56 117.32999999999942 Td (consulta de stock y respuesta automática en una única arquitectura técnica.) Tj ET
BT /F1 9 Tf 495.28 24 Td (Pagina 2) Tj ET
endstream
endobj
8 0 obj
<</Type /Font /Subtype /Type1 /BaseFont /Times-Roman /Encoding /WinAnsiEncoding >>
endobj
9 0 obj
<</Type /Font /Subtype /Type1 /BaseFont /Times-Bold /Encoding /WinAnsiEncoding >>
endobj
10 0 obj
<</Type /Font /Subtype /Type1 /BaseFont /Times-Italic /Encoding /WinAnsiEncoding >>
endobj
13 0 obj
<</Length 3436 >> stream
BT /F2 13 Tf 56 781.89 Td (5. Breve descripción de los dibujos) Tj ET
0.2 w 56 761.25 m 539.28 761.25 l S
BT /F1 11.5 Tf 56 751.25 Td (Figura 1. Diagrama de bloques general del sistema.) Tj ET
BT /F1 11.5 Tf 56 734.53 Td (Figura 2. Flujo de captura y validación del escaneo de la TSI.) Tj ET
BT /F1 11.5 Tf 56 717.81 Td (Figura 3. Flujo de contraste entre necesidad terapéutica y stock de farmacias.) Tj ET
BT /F1 11.5 Tf 56 701.0899999999999 Td (Figura 4. Flujo de acción cuando no existe stock disponible.) Tj ET
BT /F2 13 Tf 56 682.3699999999999 Td (6. Descripción detallada de la invención) Tj ET
0.2 w 56 661.7299999999999 m 539.28 661.7299999999999 l S
BT /F2 11.5 Tf 56 651.7299999999999 Td (6.1 Arquitectura general) Tj ET
BT /F1 11.5 Tf 56 637.0099999999999 Td (El sistema comprende al menos los siguientes módulos:) Tj ET
BT /F1 11.2 Tf 64 620.2899999999998 Td (1. un módulo de captura de imagen o lectura óptica para escanear la tarjeta sanitaria) Tj ET
BT /F1 11.2 Tf 64 605.9539999999998 Td ( individual;) Tj ET
BT /F1 11.2 Tf 64 591.6179999999998 Td (2. un módulo de extracción de datos para identificar los campos relevantes de la tarjeta;) Tj ET
BT /F1 11.2 Tf 64 577.2819999999998 Td (3. un módulo de verificación de identidad y autorización, vinculado al usuario) Tj ET
BT /F1 11.2 Tf 64 562.9459999999998 Td ( autenticado;) Tj ET
BT /F1 11.2 Tf 64 548.6099999999998 Td (4. un módulo de obtención de necesidades terapéuticas o medicamentos asociados;) Tj ET
BT /F1 11.2 Tf 64 534.2739999999998 Td (5. un módulo de geolocalización para obtener la posición del usuario o una posición de) Tj ET
BT /F1 11.2 Tf 64 519.9379999999998 Td ( referencia;) Tj ET
BT /F1 11.2 Tf 64 505.60199999999975 Td (6. un módulo de consulta de inventario para recuperar stock, precio y horario de) Tj ET
BT /F1 11.2 Tf 64 491.26599999999974 Td ( farmacias;) Tj ET
BT /F1 11.2 Tf 64 476.9299999999997 Td (7. un módulo de contraste y priorización para ordenar farmacias según disponibilidad y) Tj ET
BT /F1 11.2 Tf 64 462.5939999999997 Td ( cercanía;) Tj ET
BT /F1 11.2 Tf 64 448.2579999999997 Td (8. un módulo de generación de acciones para crear reserva, aviso, pedido o derivación;) Tj ET
BT /F1 11.2 Tf 64 433.9219999999997 Td (9. un módulo de trazabilidad y auditoría.) Tj ET
BT /F2 11.5 Tf 56 419.5859999999997 Td (6.2 Funcionamiento) Tj ET
BT /F1 11.5 Tf 56 404.86599999999964 Td (En una realización preferida, el usuario abre la aplicación y solicita el escaneo de) Tj ET
BT /F1 11.5 Tf 56 390.1459999999996 Td (su tarjeta sanitaria individual. El terminal captura la imagen o el código de la) Tj ET
BT /F1 11.5 Tf 56 375.4259999999996 Td (tarjeta y el módulo de extracción identifica los datos técnicos necesarios para) Tj ET
BT /F1 11.5 Tf 56 360.70599999999956 Td (enlazar el contexto sanitario del usuario con un conjunto de medicamentos o) Tj ET
BT /F1 11.5 Tf 56 345.98599999999954 Td (referencias de tratamiento.) Tj ET
BT /F1 11.5 Tf 56 329.2659999999995 Td (El sistema valida que la operación se ejecuta sobre un usuario autenticado y) Tj ET
BT /F1 11.5 Tf 56 314.5459999999995 Td (autorizado. A continuación, el motor de contraste consulta el inventario de) Tj ET
BT /F1 11.5 Tf 56 299.82599999999945 Td (farmacias cercanas, recuperando para cada farmacia al menos su ubicación, horario,) Tj ET
BT /F1 11.5 Tf 56 285.1059999999994 Td (stock y, opcionalmente, precio.) Tj ET
BT /F1 9 Tf 495.28 24 Td (Pagina 3) Tj ET
endstream
endobj
16 0 obj
<</Type /Font /Subtype /Type1 /BaseFont /Times-Roman /Encoding /WinAnsiEncoding >>
endobj
17 0 obj
<</Type /Font /Subtype /Type1 /BaseFont /Times-Bold /Encoding /WinAnsiEncoding >>
endobj
18 0 obj
<</Type /Font /Subtype /Type1 /BaseFont /Times-Italic /Encoding /WinAnsiEncoding >>
endobj
22 0 obj
<</Length 3271 >> stream
BT /F2 11.5 Tf 56 783.89 Td (6.2 Funcionamiento \(continuación\)) Tj ET
BT /F1 11.5 Tf 56 769.17 Td (El módulo de priorización calcula una orden de resultados en base a criterios) Tj ET
BT /F1 11.5 Tf 56 754.4499999999999 Td (técnicos tales como existencia de stock, nivel de stock, distancia geográfica,) Tj ET
BT /F1 11.5 Tf 56 739.7299999999999 Td (horario de apertura y capacidad de reserva o solicitud de pedido.) Tj ET
BT /F1 11.5 Tf 56 723.0099999999999 Td (Cuando existe disponibilidad, el sistema muestra una lista priorizada de farmacias y) Tj ET
BT /F1 11.5 Tf 56 708.2899999999998 Td (puede iniciar una reserva o una indicación de recogida. Cuando no existe) Tj ET
BT /F1 11.5 Tf 56 693.5699999999998 Td (disponibilidad, el sistema no se limita a informar de la falta de stock, sino que) Tj ET
BT /F1 11.5 Tf 56 678.8499999999998 Td (genera una acción técnica adicional, por ejemplo alerta a la farmacia seleccionada,) Tj ET
BT /F1 11.5 Tf 56 664.1299999999998 Td (solicitud de pedido a distribución o proveedor, derivación automática a otra) Tj ET
BT /F1 11.5 Tf 56 649.4099999999997 Td (farmacia con stock o registro de una lista de espera o interés de suministro.) Tj ET
BT /F2 11.5 Tf 56 632.6899999999997 Td (6.3 Tratamiento de datos y seguridad) Tj ET
BT /F1 11.5 Tf 56 617.9699999999997 Td (La invención prevé que la información derivada del escaneo de la TSI se trate con) Tj ET
BT /F1 11.5 Tf 56 603.2499999999997 Td (medidas de minimización de datos, control de acceso, cifrado en tránsito y registro) Tj ET
BT /F1 11.5 Tf 56 588.5299999999996 Td (de eventos. El sistema no expone innecesariamente los datos de la tarjeta sanitaria) Tj ET
BT /F1 11.5 Tf 56 573.8099999999996 Td (y solo conserva los elementos imprescindibles para la operación descrita.) Tj ET
BT /F2 11.5 Tf 56 557.0899999999996 Td (6.4 Integración con la plataforma FarmaFinder) Tj ET
BT /F1 11.5 Tf 56 542.3699999999995 Td (En una implementación concreta, el sistema puede integrarse con una plataforma que) Tj ET
BT /F1 11.5 Tf 56 527.6499999999995 Td (ya disponga de catálogo de farmacias, geolocalización, stock por medicamento,) Tj ET
BT /F1 11.5 Tf 56 512.9299999999995 Td (autenticación de usuario, notificaciones push e histórico de acciones y) Tj ET
BT /F1 11.5 Tf 56 498.20999999999947 Td (preferencias.) Tj ET
BT /F1 11.5 Tf 56 481.48999999999944 Td (La integración permite que el resultado del escaneo de la TSI no se limite a una) Tj ET
BT /F1 11.5 Tf 56 466.7699999999994 Td (consulta pasiva, sino que se convierta en un disparador de decisiones técnicas sobre) Tj ET
BT /F1 11.5 Tf 56 452.0499999999994 Td (disponibilidad y suministro.) Tj ET
BT /F2 13 Tf 56 433.32999999999936 Td (7. Ejemplo de realización) Tj ET
0.2 w 56 412.6899999999994 m 539.28 412.6899999999994 l S
BT /F1 11.2 Tf 64 402.6899999999994 Td (1. El usuario inicia sesión en la aplicación.) Tj ET
BT /F1 11.2 Tf 64 388.35399999999936 Td (2. El usuario escanea su TSI con la cámara del terminal.) Tj ET
BT /F1 11.2 Tf 64 374.01799999999935 Td (3. El sistema extrae el identificador necesario y lo vincula al perfil autorizado.) Tj ET
BT /F1 11.2 Tf 64 359.68199999999933 Td (4. El motor terapéutico asocia una necesidad de medicamento o tratamiento.) Tj ET
BT /F1 9 Tf 495.28 24 Td (Pagina 4) Tj ET
endstream
endobj
25 0 obj
<</Type /Font /Subtype /Type1 /BaseFont /Times-Roman /Encoding /WinAnsiEncoding >>
endobj
26 0 obj
<</Type /Font /Subtype /Type1 /BaseFont /Times-Bold /Encoding /WinAnsiEncoding >>
endobj
27 0 obj
<</Type /Font /Subtype /Type1 /BaseFont /Times-Italic /Encoding /WinAnsiEncoding >>
endobj
32 0 obj
<</Length 3689 >> stream
BT /F2 13 Tf 56 781.89 Td (7. Ejemplo de realización \(continuación\)) Tj ET
0.2 w 56 761.25 m 539.28 761.25 l S
BT /F1 11.2 Tf 64 751.25 Td (1. El sistema consulta farmacias cercanas con stock del medicamento.) Tj ET
BT /F1 11.2 Tf 64 736.914 Td (2. Si hay stock, ordena las farmacias por proximidad y disponibilidad.) Tj ET
BT /F1 11.2 Tf 64 722.578 Td (3. Si no hay stock, genera una reserva, una alerta o una solicitud de pedido.) Tj ET
BT /F2 13 Tf 56 706.242 Td (8. Reivindicaciones) Tj ET
0.2 w 56 685.602 m 539.28 685.602 l S
BT /F1 11.2 Tf 56 675.602 Td (Reivindicación 1. Sistema implementado por ordenador para identificar una necesidad) Tj ET
BT /F1 11.2 Tf 56 661.266 Td (terapéutica a partir del escaneo de una tarjeta sanitaria individual, caracterizado) Tj ET
BT /F1 11.2 Tf 56 646.93 Td (porque comprende medios de captura para obtener datos de la tarjeta sanitaria) Tj ET
BT /F1 11.2 Tf 56 632.5939999999999 Td (individual; medios de verificación para asociar dichos datos a un usuario autenticado;) Tj ET
BT /F1 11.2 Tf 56 618.2579999999999 Td (medios de obtención de una referencia de medicamento o necesidad terapéutica; medios) Tj ET
BT /F1 11.2 Tf 56 603.9219999999999 Td (de consulta de inventario para recuperar stock de farmacias geolocalizadas; medios de) Tj ET
BT /F1 11.2 Tf 56 589.5859999999999 Td (contraste para comparar la referencia de medicamento o necesidad terapéutica con el) Tj ET
BT /F1 11.2 Tf 56 575.2499999999999 Td (stock disponible; y medios de generación de acciones para producir una reserva, aviso,) Tj ET
BT /F1 11.2 Tf 56 560.9139999999999 Td (pedido o derivación cuando el stock disponible sea insuficiente.) Tj ET
BT /F1 11.2 Tf 56 544.5779999999999 Td (Reivindicación 2. Sistema según la reivindicación 1, en el que los medios de consulta) Tj ET
BT /F1 11.2 Tf 56 530.2419999999998 Td (de inventario ordenan las farmacias según una combinación de stock disponible,) Tj ET
BT /F1 11.2 Tf 56 515.9059999999998 Td (proximidad geográfica y horario de apertura.) Tj ET
BT /F1 11.2 Tf 56 499.5699999999998 Td (Reivindicación 3. Sistema según cualquiera de las reivindicaciones anteriores, en el) Tj ET
BT /F1 11.2 Tf 56 485.2339999999998 Td (que los datos de la tarjeta sanitaria individual son tratados mediante un módulo de) Tj ET
BT /F1 11.2 Tf 56 470.8979999999998 Td (minimización y cifrado para limitar su almacenamiento a los campos técnicamente) Tj ET
BT /F1 11.2 Tf 56 456.5619999999998 Td (necesarios.) Tj ET
BT /F1 11.2 Tf 56 440.2259999999998 Td (Reivindicación 4. Procedimiento implementado por ordenador para asistir en la) Tj ET
BT /F1 11.2 Tf 56 425.88999999999976 Td (localización y suministro de medicamentos, caracterizado porque comprende escanear una) Tj ET
BT /F1 11.2 Tf 56 411.55399999999975 Td (tarjeta sanitaria individual; verificar la autorización del usuario; derivar una) Tj ET
BT /F1 11.2 Tf 56 397.21799999999973 Td (necesidad terapéutica o referencia de medicamento; consultar stock de farmacias) Tj ET
BT /F1 11.2 Tf 56 382.8819999999997 Td (geolocalizadas; comparar la necesidad con el stock disponible; y generar una acción) Tj ET
BT /F1 11.2 Tf 56 368.5459999999997 Td (técnica de reserva, aviso, pedido o derivación cuando no exista disponibilidad) Tj ET
BT /F1 11.2 Tf 56 354.2099999999997 Td (suficiente.) Tj ET
BT /F1 11.2 Tf 56 337.8739999999997 Td (Reivindicación 5. Dispositivo móvil, terminal o sistema de lectura que comprende una) Tj ET
BT /F1 11.2 Tf 56 323.53799999999967 Td (cámara o lector óptico y que está configurado para ejecutar el sistema de cualquiera) Tj ET
BT /F1 11.2 Tf 56 309.20199999999966 Td (de las reivindicaciones anteriores.) Tj ET
BT /F1 9 Tf 495.28 24 Td (Pagina 5) Tj ET
endstream
endobj
34 0 obj
<< /Type /Catalog /Pages 35 0 R>>
endobj
35 0 obj
<< /Type /Pages /Kids [ 36 0 R 37 0 R 38 0 R 39 0 R ] /Count 4 >>
endobj
36 0 obj
<< /Type /Page /Parent 35 0 R /Contents 5 0 R /Resources <</Font <</F1 1 0 R /F2 2 0 R /F3 3 0 R >> >> /MediaBox [0 0 595.28 841.89 ] /TrimBox [0 0 595.28 841.89 ] /Rotate 0 >>
endobj
37 0 obj
<< /Type /Page /Parent 35 0 R /Contents 13 0 R /Resources <</Font <</F1 8 0 R /F2 9 0 R /F3 10 0 R >> >> /MediaBox [0 0 595.28 841.89 ] /TrimBox [0 0 595.28 841.89 ] /Rotate 0 >>
endobj
38 0 obj
<< /Type /Page /Parent 35 0 R /Contents 22 0 R /Resources <</Font <</F1 16 0 R /F2 17 0 R /F3 18 0 R >> >> /MediaBox [0 0 595.28 841.89 ] /TrimBox [0 0 595.28 841.89 ] /Rotate 0 >>
endobj
39 0 obj
<< /Type /Page /Parent 35 0 R /Contents 32 0 R /Resources <</Font <</F1 25 0 R /F2 26 0 R /F3 27 0 R >> >> /MediaBox [0 0 595.28 841.89 ] /TrimBox [0 0 595.28 841.89 ] /Rotate 0 >>
endobj
xref
0 40
0000000004 65535 f
0000000015 00000 n
0000000117 00000 n
0000000218 00000 n
0000000006 00000 f
0000000321 00000 n
0000000007 00000 f
0000000011 00000 f
0000005329 00000 n
0000005431 00000 n
0000005532 00000 n
0000000012 00000 f
0000000014 00000 f
0000005636 00000 n
0000000015 00000 f
0000000019 00000 f
0000009131 00000 n
0000009234 00000 n
0000009336 00000 n
0000000020 00000 f
0000000021 00000 f
0000000023 00000 f
0000009440 00000 n
0000000024 00000 f
0000000028 00000 f
0000012770 00000 n
0000012873 00000 n
0000012975 00000 n
0000000029 00000 f
0000000030 00000 f
0000000031 00000 f
0000000033 00000 f
0000013079 00000 n
0000000000 00000 f
0000016827 00000 n
0000016877 00000 n
0000016959 00000 n
0000017158 00000 n
0000017359 00000 n
0000017562 00000 n
trailer
<</Size 22 /ID [(J®¡já¦�é÷¿\ng/) (J®¡já¦�é÷¿\ng/) ] /Root 34 0 R >>
startxref
17765
%%EOF
@@ -0,0 +1,206 @@
%PDF-1.4
%âãÏÓ
1 0 obj
<</Type /Font /Subtype /Type1 /BaseFont /Times-Roman /Encoding /WinAnsiEncoding >>
endobj
2 0 obj
<</Type /Font /Subtype /Type1 /BaseFont /Times-Bold /Encoding /WinAnsiEncoding >>
endobj
3 0 obj
<</Type /Font /Subtype /Type1 /BaseFont /Times-Italic /Encoding /WinAnsiEncoding >>
endobj
5 0 obj
<</Length 4950 >> stream
BT /F2 13 Tf 56 781.89 Td (1. Campo técnico de la invención) Tj ET
0.2 w 56 761.25 m 539.28 761.25 l S
BT /F1 11.5 Tf 56 751.25 Td (La invención se encuadra en los sistemas informáticos aplicados al sector sanitario) Tj ET
BT /F1 11.5 Tf 56 736.53 Td (y farmacéutico, en particular en soluciones de captura de identificadores) Tj ET
BT /F1 11.5 Tf 56 721.81 Td (sanitarios, comparación automatizada de disponibilidad de medicamentos y asistencia) Tj ET
BT /F1 11.5 Tf 56 707.0899999999999 Td (digital a la dispensación y suministro.) Tj ET
BT /F2 13 Tf 56 688.3699999999999 Td (2. Antecedentes de la invención) Tj ET
0.2 w 56 667.7299999999999 m 539.28 667.7299999999999 l S
BT /F1 11.5 Tf 56 657.7299999999999 Td (Las soluciones actuales de búsqueda de medicamentos y farmacias permiten localizar) Tj ET
BT /F1 11.5 Tf 56 643.0099999999999 Td (productos y mostrar stock aproximado, pero normalmente operan a partir de una) Tj ET
BT /F1 11.5 Tf 56 628.2899999999998 Td (búsqueda manual por nombre de medicamento o principio activo. También existen) Tj ET
BT /F1 11.5 Tf 56 613.5699999999998 Td (sistemas de geolocalización y de gestión de inventario, pero suelen estar) Tj ET
BT /F1 11.5 Tf 56 598.8499999999998 Td (desacoplados del contexto sanitario del usuario y no integran, en una única cadena) Tj ET
BT /F1 11.5 Tf 56 584.1299999999998 Td (técnica, la identificación mediante tarjeta sanitaria individual, la inferencia de) Tj ET
BT /F1 11.5 Tf 56 569.4099999999997 Td (necesidades terapéuticas y la comparación en tiempo real con disponibilidad) Tj ET
BT /F1 11.5 Tf 56 554.6899999999997 Td (farmacéutica local.) Tj ET
BT /F1 11.5 Tf 56 537.9699999999997 Td (Además, cuando no existe stock en la farmacia más próxima, el usuario debe iniciar) Tj ET
BT /F1 11.5 Tf 56 523.2499999999997 Td (manualmente nuevos procesos de búsqueda o desplazamiento, sin que el sistema) Tj ET
BT /F1 11.5 Tf 56 508.52999999999963 Td (proponga de forma automatizada acciones técnicas de reserva, pedido o derivación) Tj ET
BT /F1 11.5 Tf 56 493.8099999999996 Td (basadas en la disponibilidad real.) Tj ET
BT /F2 13 Tf 56 475.0899999999996 Td (3. Problema técnico a resolver) Tj ET
0.2 w 56 454.4499999999996 m 539.28 454.4499999999996 l S
BT /F1 11.5 Tf 56 444.4499999999996 Td (La invención resuelve el problema técnico de reducir el tiempo y la fricción entre) Tj ET
BT /F1 11.5 Tf 56 429.72999999999956 Td (la identificación de una necesidad terapéutica y la localización de una farmacia que) Tj ET
BT /F1 11.5 Tf 56 415.00999999999954 Td (disponga del medicamento adecuado, integrando:) Tj ET
BT /F1 11.2 Tf 64 398.2899999999995 Td (" lectura segura de identificadores sanitarios,) Tj ET
BT /F1 11.2 Tf 64 383.9539999999995 Td (" obtención de referencias de medicamento asociadas,) Tj ET
BT /F1 11.2 Tf 64 369.6179999999995 Td (" comparación automatizada con inventarios de farmacias,) Tj ET
BT /F1 11.2 Tf 64 355.28199999999947 Td (" priorización por proximidad y disponibilidad,) Tj ET
BT /F1 11.2 Tf 64 340.94599999999946 Td (" generación de acciones técnicas de suministro cuando no exista stock inmediato.) Tj ET
BT /F2 13 Tf 56 324.60999999999945 Td (4. Resumen de la invención) Tj ET
0.2 w 56 303.96999999999946 m 539.28 303.96999999999946 l S
BT /F1 11.5 Tf 56 293.96999999999946 Td (La invención propone un sistema y procedimiento implementados por ordenador que) Tj ET
BT /F1 11.5 Tf 56 279.24999999999943 Td (operan sobre un terminal móvil o dispositivo de lectura. El sistema captura la) Tj ET
BT /F1 11.5 Tf 56 264.5299999999994 Td (información de una tarjeta sanitaria individual mediante escaneo, valida la) Tj ET
BT /F1 11.5 Tf 56 249.8099999999994 Td (información mediante un módulo de verificación, asocia el resultado al perfil del) Tj ET
BT /F1 11.5 Tf 56 235.0899999999994 Td (usuario autenticado y obtiene una lista de medicamentos o necesidades terapéuticas) Tj ET
BT /F1 11.5 Tf 56 220.3699999999994 Td (relevantes. Posteriormente, un motor de contraste consulta un repositorio de stock) Tj ET
BT /F1 11.5 Tf 56 205.6499999999994 Td (de farmacias geolocalizadas y determina la coincidencia entre la necesidad detectada) Tj ET
BT /F1 11.5 Tf 56 190.9299999999994 Td (y la disponibilidad real en cada establecimiento. Si existe stock, el sistema) Tj ET
BT /F1 11.5 Tf 56 176.2099999999994 Td (prioriza las farmacias adecuadas y presenta una ruta de acceso o reserva. Si no) Tj ET
BT /F1 11.5 Tf 56 161.4899999999994 Td (existe stock, el sistema genera automáticamente una acción de aviso, pedido, reserva) Tj ET
BT /F1 11.5 Tf 56 146.7699999999994 Td (o derivación, conservando trazabilidad técnica del evento. La invención no se limita) Tj ET
BT /F1 11.5 Tf 56 132.04999999999941 Td (a una búsqueda manual, sino que integra captura, validación, georreferenciación,) Tj ET
BT /F1 11.5 Tf 56 117.32999999999942 Td (consulta de stock y respuesta automática en una única arquitectura técnica.) Tj ET
BT /F1 9 Tf 495.28 24 Td (Pagina 2) Tj ET
endstream
endobj
8 0 obj
<</Type /Font /Subtype /Type1 /BaseFont /Times-Roman /Encoding /WinAnsiEncoding >>
endobj
9 0 obj
<</Type /Font /Subtype /Type1 /BaseFont /Times-Bold /Encoding /WinAnsiEncoding >>
endobj
10 0 obj
<</Type /Font /Subtype /Type1 /BaseFont /Times-Italic /Encoding /WinAnsiEncoding >>
endobj
13 0 obj
<</Length 3436 >> stream
BT /F2 13 Tf 56 781.89 Td (5. Breve descripción de los dibujos) Tj ET
0.2 w 56 761.25 m 539.28 761.25 l S
BT /F1 11.5 Tf 56 751.25 Td (Figura 1. Diagrama de bloques general del sistema.) Tj ET
BT /F1 11.5 Tf 56 734.53 Td (Figura 2. Flujo de captura y validación del escaneo de la TSI.) Tj ET
BT /F1 11.5 Tf 56 717.81 Td (Figura 3. Flujo de contraste entre necesidad terapéutica y stock de farmacias.) Tj ET
BT /F1 11.5 Tf 56 701.0899999999999 Td (Figura 4. Flujo de acción cuando no existe stock disponible.) Tj ET
BT /F2 13 Tf 56 682.3699999999999 Td (6. Descripción detallada de la invención) Tj ET
0.2 w 56 661.7299999999999 m 539.28 661.7299999999999 l S
BT /F2 11.5 Tf 56 651.7299999999999 Td (6.1 Arquitectura general) Tj ET
BT /F1 11.5 Tf 56 637.0099999999999 Td (El sistema comprende al menos los siguientes módulos:) Tj ET
BT /F1 11.2 Tf 64 620.2899999999998 Td (1. un módulo de captura de imagen o lectura óptica para escanear la tarjeta sanitaria) Tj ET
BT /F1 11.2 Tf 64 605.9539999999998 Td ( individual;) Tj ET
BT /F1 11.2 Tf 64 591.6179999999998 Td (2. un módulo de extracción de datos para identificar los campos relevantes de la tarjeta;) Tj ET
BT /F1 11.2 Tf 64 577.2819999999998 Td (3. un módulo de verificación de identidad y autorización, vinculado al usuario) Tj ET
BT /F1 11.2 Tf 64 562.9459999999998 Td ( autenticado;) Tj ET
BT /F1 11.2 Tf 64 548.6099999999998 Td (4. un módulo de obtención de necesidades terapéuticas o medicamentos asociados;) Tj ET
BT /F1 11.2 Tf 64 534.2739999999998 Td (5. un módulo de geolocalización para obtener la posición del usuario o una posición de) Tj ET
BT /F1 11.2 Tf 64 519.9379999999998 Td ( referencia;) Tj ET
BT /F1 11.2 Tf 64 505.60199999999975 Td (6. un módulo de consulta de inventario para recuperar stock, precio y horario de) Tj ET
BT /F1 11.2 Tf 64 491.26599999999974 Td ( farmacias;) Tj ET
BT /F1 11.2 Tf 64 476.9299999999997 Td (7. un módulo de contraste y priorización para ordenar farmacias según disponibilidad y) Tj ET
BT /F1 11.2 Tf 64 462.5939999999997 Td ( cercanía;) Tj ET
BT /F1 11.2 Tf 64 448.2579999999997 Td (8. un módulo de generación de acciones para crear reserva, aviso, pedido o derivación;) Tj ET
BT /F1 11.2 Tf 64 433.9219999999997 Td (9. un módulo de trazabilidad y auditoría.) Tj ET
BT /F2 11.5 Tf 56 419.5859999999997 Td (6.2 Funcionamiento) Tj ET
BT /F1 11.5 Tf 56 404.86599999999964 Td (En una realización preferida, el usuario abre la aplicación y solicita el escaneo de) Tj ET
BT /F1 11.5 Tf 56 390.1459999999996 Td (su tarjeta sanitaria individual. El terminal captura la imagen o el código de la) Tj ET
BT /F1 11.5 Tf 56 375.4259999999996 Td (tarjeta y el módulo de extracción identifica los datos técnicos necesarios para) Tj ET
BT /F1 11.5 Tf 56 360.70599999999956 Td (enlazar el contexto sanitario del usuario con un conjunto de medicamentos o) Tj ET
BT /F1 11.5 Tf 56 345.98599999999954 Td (referencias de tratamiento.) Tj ET
BT /F1 11.5 Tf 56 329.2659999999995 Td (El sistema valida que la operación se ejecuta sobre un usuario autenticado y) Tj ET
BT /F1 11.5 Tf 56 314.5459999999995 Td (autorizado. A continuación, el motor de contraste consulta el inventario de) Tj ET
BT /F1 11.5 Tf 56 299.82599999999945 Td (farmacias cercanas, recuperando para cada farmacia al menos su ubicación, horario,) Tj ET
BT /F1 11.5 Tf 56 285.1059999999994 Td (stock y, opcionalmente, precio.) Tj ET
BT /F1 9 Tf 495.28 24 Td (Pagina 3) Tj ET
endstream
endobj
16 0 obj
<</Type /Font /Subtype /Type1 /BaseFont /Times-Roman /Encoding /WinAnsiEncoding >>
endobj
17 0 obj
<</Type /Font /Subtype /Type1 /BaseFont /Times-Bold /Encoding /WinAnsiEncoding >>
endobj
18 0 obj
<</Type /Font /Subtype /Type1 /BaseFont /Times-Italic /Encoding /WinAnsiEncoding >>
endobj
22 0 obj
<</Length 3271 >> stream
BT /F2 11.5 Tf 56 783.89 Td (6.2 Funcionamiento \(continuación\)) Tj ET
BT /F1 11.5 Tf 56 769.17 Td (El módulo de priorización calcula una orden de resultados en base a criterios) Tj ET
BT /F1 11.5 Tf 56 754.4499999999999 Td (técnicos tales como existencia de stock, nivel de stock, distancia geográfica,) Tj ET
BT /F1 11.5 Tf 56 739.7299999999999 Td (horario de apertura y capacidad de reserva o solicitud de pedido.) Tj ET
BT /F1 11.5 Tf 56 723.0099999999999 Td (Cuando existe disponibilidad, el sistema muestra una lista priorizada de farmacias y) Tj ET
BT /F1 11.5 Tf 56 708.2899999999998 Td (puede iniciar una reserva o una indicación de recogida. Cuando no existe) Tj ET
BT /F1 11.5 Tf 56 693.5699999999998 Td (disponibilidad, el sistema no se limita a informar de la falta de stock, sino que) Tj ET
BT /F1 11.5 Tf 56 678.8499999999998 Td (genera una acción técnica adicional, por ejemplo alerta a la farmacia seleccionada,) Tj ET
BT /F1 11.5 Tf 56 664.1299999999998 Td (solicitud de pedido a distribución o proveedor, derivación automática a otra) Tj ET
BT /F1 11.5 Tf 56 649.4099999999997 Td (farmacia con stock o registro de una lista de espera o interés de suministro.) Tj ET
BT /F2 11.5 Tf 56 632.6899999999997 Td (6.3 Tratamiento de datos y seguridad) Tj ET
BT /F1 11.5 Tf 56 617.9699999999997 Td (La invención prevé que la información derivada del escaneo de la TSI se trate con) Tj ET
BT /F1 11.5 Tf 56 603.2499999999997 Td (medidas de minimización de datos, control de acceso, cifrado en tránsito y registro) Tj ET
BT /F1 11.5 Tf 56 588.5299999999996 Td (de eventos. El sistema no expone innecesariamente los datos de la tarjeta sanitaria) Tj ET
BT /F1 11.5 Tf 56 573.8099999999996 Td (y solo conserva los elementos imprescindibles para la operación descrita.) Tj ET
BT /F2 11.5 Tf 56 557.0899999999996 Td (6.4 Integración con la plataforma FarmaFinder) Tj ET
BT /F1 11.5 Tf 56 542.3699999999995 Td (En una implementación concreta, el sistema puede integrarse con una plataforma que) Tj ET
BT /F1 11.5 Tf 56 527.6499999999995 Td (ya disponga de catálogo de farmacias, geolocalización, stock por medicamento,) Tj ET
BT /F1 11.5 Tf 56 512.9299999999995 Td (autenticación de usuario, notificaciones push e histórico de acciones y) Tj ET
BT /F1 11.5 Tf 56 498.20999999999947 Td (preferencias.) Tj ET
BT /F1 11.5 Tf 56 481.48999999999944 Td (La integración permite que el resultado del escaneo de la TSI no se limite a una) Tj ET
BT /F1 11.5 Tf 56 466.7699999999994 Td (consulta pasiva, sino que se convierta en un disparador de decisiones técnicas sobre) Tj ET
BT /F1 11.5 Tf 56 452.0499999999994 Td (disponibilidad y suministro.) Tj ET
BT /F2 13 Tf 56 433.32999999999936 Td (7. Ejemplo de realización) Tj ET
0.2 w 56 412.6899999999994 m 539.28 412.6899999999994 l S
BT /F1 11.2 Tf 64 402.6899999999994 Td (1. El usuario inicia sesión en la aplicación.) Tj ET
BT /F1 11.2 Tf 64 388.35399999999936 Td (2. El usuario escanea su TSI con la cámara del terminal.) Tj ET
BT /F1 11.2 Tf 64 374.01799999999935 Td (3. El sistema extrae el identificador necesario y lo vincula al perfil autorizado.) Tj ET
BT /F1 11.2 Tf 64 359.68199999999933 Td (4. El motor terapéutico asocia una necesidad de medicamento o tratamiento.) Tj ET
BT /F1 9 Tf 495.28 24 Td (Pagina 4) Tj ET
endstream
endobj
24 0 obj
<< /Type /Catalog /Pages 25 0 R>>
endobj
25 0 obj
<< /Type /Pages /Kids [ 26 0 R 27 0 R 28 0 R ] /Count 3 >>
endobj
26 0 obj
<< /Type /Page /Parent 25 0 R /Contents 5 0 R /Resources <</Font <</F1 1 0 R /F2 2 0 R /F3 3 0 R >> >> /MediaBox [0 0 595.28 841.89 ] /TrimBox [0 0 595.28 841.89 ] /Rotate 0 >>
endobj
27 0 obj
<< /Type /Page /Parent 25 0 R /Contents 13 0 R /Resources <</Font <</F1 8 0 R /F2 9 0 R /F3 10 0 R >> >> /MediaBox [0 0 595.28 841.89 ] /TrimBox [0 0 595.28 841.89 ] /Rotate 0 >>
endobj
28 0 obj
<< /Type /Page /Parent 25 0 R /Contents 22 0 R /Resources <</Font <</F1 16 0 R /F2 17 0 R /F3 18 0 R >> >> /MediaBox [0 0 595.28 841.89 ] /TrimBox [0 0 595.28 841.89 ] /Rotate 0 >>
endobj
xref
0 29
0000000004 65535 f
0000000015 00000 n
0000000117 00000 n
0000000218 00000 n
0000000006 00000 f
0000000321 00000 n
0000000007 00000 f
0000000011 00000 f
0000005329 00000 n
0000005431 00000 n
0000005532 00000 n
0000000012 00000 f
0000000014 00000 f
0000005636 00000 n
0000000015 00000 f
0000000019 00000 f
0000009131 00000 n
0000009234 00000 n
0000009336 00000 n
0000000020 00000 f
0000000021 00000 f
0000000023 00000 f
0000009440 00000 n
0000000000 00000 f
0000012770 00000 n
0000012820 00000 n
0000012895 00000 n
0000013094 00000 n
0000013295 00000 n
trailer
<</Size 17 /ID [(=ÁI‚³ŸÁX’âÖ®) (=ÁI‚³ŸÁX’âÖ®) ] /Root 24 0 R >>
startxref
13498
%%EOF
@@ -0,0 +1,269 @@
%PDF-1.4
%âãÏÓ
1 0 obj
<</Type /Font /Subtype /Type1 /BaseFont /Times-Roman /Encoding /WinAnsiEncoding >>
endobj
2 0 obj
<</Type /Font /Subtype /Type1 /BaseFont /Times-Bold /Encoding /WinAnsiEncoding >>
endobj
3 0 obj
<</Type /Font /Subtype /Type1 /BaseFont /Times-Italic /Encoding /WinAnsiEncoding >>
endobj
6 0 obj
<</Length 3436 >> stream
BT /F2 13 Tf 56 781.89 Td (5. Breve descripción de los dibujos) Tj ET
0.2 w 56 761.25 m 539.28 761.25 l S
BT /F1 11.5 Tf 56 751.25 Td (Figura 1. Diagrama de bloques general del sistema.) Tj ET
BT /F1 11.5 Tf 56 734.53 Td (Figura 2. Flujo de captura y validación del escaneo de la TSI.) Tj ET
BT /F1 11.5 Tf 56 717.81 Td (Figura 3. Flujo de contraste entre necesidad terapéutica y stock de farmacias.) Tj ET
BT /F1 11.5 Tf 56 701.0899999999999 Td (Figura 4. Flujo de acción cuando no existe stock disponible.) Tj ET
BT /F2 13 Tf 56 682.3699999999999 Td (6. Descripción detallada de la invención) Tj ET
0.2 w 56 661.7299999999999 m 539.28 661.7299999999999 l S
BT /F2 11.5 Tf 56 651.7299999999999 Td (6.1 Arquitectura general) Tj ET
BT /F1 11.5 Tf 56 637.0099999999999 Td (El sistema comprende al menos los siguientes módulos:) Tj ET
BT /F1 11.2 Tf 64 620.2899999999998 Td (1. un módulo de captura de imagen o lectura óptica para escanear la tarjeta sanitaria) Tj ET
BT /F1 11.2 Tf 64 605.9539999999998 Td ( individual;) Tj ET
BT /F1 11.2 Tf 64 591.6179999999998 Td (2. un módulo de extracción de datos para identificar los campos relevantes de la tarjeta;) Tj ET
BT /F1 11.2 Tf 64 577.2819999999998 Td (3. un módulo de verificación de identidad y autorización, vinculado al usuario) Tj ET
BT /F1 11.2 Tf 64 562.9459999999998 Td ( autenticado;) Tj ET
BT /F1 11.2 Tf 64 548.6099999999998 Td (4. un módulo de obtención de necesidades terapéuticas o medicamentos asociados;) Tj ET
BT /F1 11.2 Tf 64 534.2739999999998 Td (5. un módulo de geolocalización para obtener la posición del usuario o una posición de) Tj ET
BT /F1 11.2 Tf 64 519.9379999999998 Td ( referencia;) Tj ET
BT /F1 11.2 Tf 64 505.60199999999975 Td (6. un módulo de consulta de inventario para recuperar stock, precio y horario de) Tj ET
BT /F1 11.2 Tf 64 491.26599999999974 Td ( farmacias;) Tj ET
BT /F1 11.2 Tf 64 476.9299999999997 Td (7. un módulo de contraste y priorización para ordenar farmacias según disponibilidad y) Tj ET
BT /F1 11.2 Tf 64 462.5939999999997 Td ( cercanía;) Tj ET
BT /F1 11.2 Tf 64 448.2579999999997 Td (8. un módulo de generación de acciones para crear reserva, aviso, pedido o derivación;) Tj ET
BT /F1 11.2 Tf 64 433.9219999999997 Td (9. un módulo de trazabilidad y auditoría.) Tj ET
BT /F2 11.5 Tf 56 419.5859999999997 Td (6.2 Funcionamiento) Tj ET
BT /F1 11.5 Tf 56 404.86599999999964 Td (En una realización preferida, el usuario abre la aplicación y solicita el escaneo de) Tj ET
BT /F1 11.5 Tf 56 390.1459999999996 Td (su tarjeta sanitaria individual. El terminal captura la imagen o el código de la) Tj ET
BT /F1 11.5 Tf 56 375.4259999999996 Td (tarjeta y el módulo de extracción identifica los datos técnicos necesarios para) Tj ET
BT /F1 11.5 Tf 56 360.70599999999956 Td (enlazar el contexto sanitario del usuario con un conjunto de medicamentos o) Tj ET
BT /F1 11.5 Tf 56 345.98599999999954 Td (referencias de tratamiento.) Tj ET
BT /F1 11.5 Tf 56 329.2659999999995 Td (El sistema valida que la operación se ejecuta sobre un usuario autenticado y) Tj ET
BT /F1 11.5 Tf 56 314.5459999999995 Td (autorizado. A continuación, el motor de contraste consulta el inventario de) Tj ET
BT /F1 11.5 Tf 56 299.82599999999945 Td (farmacias cercanas, recuperando para cada farmacia al menos su ubicación, horario,) Tj ET
BT /F1 11.5 Tf 56 285.1059999999994 Td (stock y, opcionalmente, precio.) Tj ET
BT /F1 9 Tf 495.28 24 Td (Pagina 3) Tj ET
endstream
endobj
9 0 obj
<</Type /Font /Subtype /Type1 /BaseFont /Times-Roman /Encoding /WinAnsiEncoding >>
endobj
10 0 obj
<</Type /Font /Subtype /Type1 /BaseFont /Times-Bold /Encoding /WinAnsiEncoding >>
endobj
11 0 obj
<</Type /Font /Subtype /Type1 /BaseFont /Times-Italic /Encoding /WinAnsiEncoding >>
endobj
15 0 obj
<</Length 3271 >> stream
BT /F2 11.5 Tf 56 783.89 Td (6.2 Funcionamiento \(continuación\)) Tj ET
BT /F1 11.5 Tf 56 769.17 Td (El módulo de priorización calcula una orden de resultados en base a criterios) Tj ET
BT /F1 11.5 Tf 56 754.4499999999999 Td (técnicos tales como existencia de stock, nivel de stock, distancia geográfica,) Tj ET
BT /F1 11.5 Tf 56 739.7299999999999 Td (horario de apertura y capacidad de reserva o solicitud de pedido.) Tj ET
BT /F1 11.5 Tf 56 723.0099999999999 Td (Cuando existe disponibilidad, el sistema muestra una lista priorizada de farmacias y) Tj ET
BT /F1 11.5 Tf 56 708.2899999999998 Td (puede iniciar una reserva o una indicación de recogida. Cuando no existe) Tj ET
BT /F1 11.5 Tf 56 693.5699999999998 Td (disponibilidad, el sistema no se limita a informar de la falta de stock, sino que) Tj ET
BT /F1 11.5 Tf 56 678.8499999999998 Td (genera una acción técnica adicional, por ejemplo alerta a la farmacia seleccionada,) Tj ET
BT /F1 11.5 Tf 56 664.1299999999998 Td (solicitud de pedido a distribución o proveedor, derivación automática a otra) Tj ET
BT /F1 11.5 Tf 56 649.4099999999997 Td (farmacia con stock o registro de una lista de espera o interés de suministro.) Tj ET
BT /F2 11.5 Tf 56 632.6899999999997 Td (6.3 Tratamiento de datos y seguridad) Tj ET
BT /F1 11.5 Tf 56 617.9699999999997 Td (La invención prevé que la información derivada del escaneo de la TSI se trate con) Tj ET
BT /F1 11.5 Tf 56 603.2499999999997 Td (medidas de minimización de datos, control de acceso, cifrado en tránsito y registro) Tj ET
BT /F1 11.5 Tf 56 588.5299999999996 Td (de eventos. El sistema no expone innecesariamente los datos de la tarjeta sanitaria) Tj ET
BT /F1 11.5 Tf 56 573.8099999999996 Td (y solo conserva los elementos imprescindibles para la operación descrita.) Tj ET
BT /F2 11.5 Tf 56 557.0899999999996 Td (6.4 Integración con la plataforma FarmaFinder) Tj ET
BT /F1 11.5 Tf 56 542.3699999999995 Td (En una implementación concreta, el sistema puede integrarse con una plataforma que) Tj ET
BT /F1 11.5 Tf 56 527.6499999999995 Td (ya disponga de catálogo de farmacias, geolocalización, stock por medicamento,) Tj ET
BT /F1 11.5 Tf 56 512.9299999999995 Td (autenticación de usuario, notificaciones push e histórico de acciones y) Tj ET
BT /F1 11.5 Tf 56 498.20999999999947 Td (preferencias.) Tj ET
BT /F1 11.5 Tf 56 481.48999999999944 Td (La integración permite que el resultado del escaneo de la TSI no se limite a una) Tj ET
BT /F1 11.5 Tf 56 466.7699999999994 Td (consulta pasiva, sino que se convierta en un disparador de decisiones técnicas sobre) Tj ET
BT /F1 11.5 Tf 56 452.0499999999994 Td (disponibilidad y suministro.) Tj ET
BT /F2 13 Tf 56 433.32999999999936 Td (7. Ejemplo de realización) Tj ET
0.2 w 56 412.6899999999994 m 539.28 412.6899999999994 l S
BT /F1 11.2 Tf 64 402.6899999999994 Td (1. El usuario inicia sesión en la aplicación.) Tj ET
BT /F1 11.2 Tf 64 388.35399999999936 Td (2. El usuario escanea su TSI con la cámara del terminal.) Tj ET
BT /F1 11.2 Tf 64 374.01799999999935 Td (3. El sistema extrae el identificador necesario y lo vincula al perfil autorizado.) Tj ET
BT /F1 11.2 Tf 64 359.68199999999933 Td (4. El motor terapéutico asocia una necesidad de medicamento o tratamiento.) Tj ET
BT /F1 9 Tf 495.28 24 Td (Pagina 4) Tj ET
endstream
endobj
18 0 obj
<</Type /Font /Subtype /Type1 /BaseFont /Times-Roman /Encoding /WinAnsiEncoding >>
endobj
19 0 obj
<</Type /Font /Subtype /Type1 /BaseFont /Times-Bold /Encoding /WinAnsiEncoding >>
endobj
20 0 obj
<</Type /Font /Subtype /Type1 /BaseFont /Times-Italic /Encoding /WinAnsiEncoding >>
endobj
25 0 obj
<</Length 3689 >> stream
BT /F2 13 Tf 56 781.89 Td (7. Ejemplo de realización \(continuación\)) Tj ET
0.2 w 56 761.25 m 539.28 761.25 l S
BT /F1 11.2 Tf 64 751.25 Td (1. El sistema consulta farmacias cercanas con stock del medicamento.) Tj ET
BT /F1 11.2 Tf 64 736.914 Td (2. Si hay stock, ordena las farmacias por proximidad y disponibilidad.) Tj ET
BT /F1 11.2 Tf 64 722.578 Td (3. Si no hay stock, genera una reserva, una alerta o una solicitud de pedido.) Tj ET
BT /F2 13 Tf 56 706.242 Td (8. Reivindicaciones) Tj ET
0.2 w 56 685.602 m 539.28 685.602 l S
BT /F1 11.2 Tf 56 675.602 Td (Reivindicación 1. Sistema implementado por ordenador para identificar una necesidad) Tj ET
BT /F1 11.2 Tf 56 661.266 Td (terapéutica a partir del escaneo de una tarjeta sanitaria individual, caracterizado) Tj ET
BT /F1 11.2 Tf 56 646.93 Td (porque comprende medios de captura para obtener datos de la tarjeta sanitaria) Tj ET
BT /F1 11.2 Tf 56 632.5939999999999 Td (individual; medios de verificación para asociar dichos datos a un usuario autenticado;) Tj ET
BT /F1 11.2 Tf 56 618.2579999999999 Td (medios de obtención de una referencia de medicamento o necesidad terapéutica; medios) Tj ET
BT /F1 11.2 Tf 56 603.9219999999999 Td (de consulta de inventario para recuperar stock de farmacias geolocalizadas; medios de) Tj ET
BT /F1 11.2 Tf 56 589.5859999999999 Td (contraste para comparar la referencia de medicamento o necesidad terapéutica con el) Tj ET
BT /F1 11.2 Tf 56 575.2499999999999 Td (stock disponible; y medios de generación de acciones para producir una reserva, aviso,) Tj ET
BT /F1 11.2 Tf 56 560.9139999999999 Td (pedido o derivación cuando el stock disponible sea insuficiente.) Tj ET
BT /F1 11.2 Tf 56 544.5779999999999 Td (Reivindicación 2. Sistema según la reivindicación 1, en el que los medios de consulta) Tj ET
BT /F1 11.2 Tf 56 530.2419999999998 Td (de inventario ordenan las farmacias según una combinación de stock disponible,) Tj ET
BT /F1 11.2 Tf 56 515.9059999999998 Td (proximidad geográfica y horario de apertura.) Tj ET
BT /F1 11.2 Tf 56 499.5699999999998 Td (Reivindicación 3. Sistema según cualquiera de las reivindicaciones anteriores, en el) Tj ET
BT /F1 11.2 Tf 56 485.2339999999998 Td (que los datos de la tarjeta sanitaria individual son tratados mediante un módulo de) Tj ET
BT /F1 11.2 Tf 56 470.8979999999998 Td (minimización y cifrado para limitar su almacenamiento a los campos técnicamente) Tj ET
BT /F1 11.2 Tf 56 456.5619999999998 Td (necesarios.) Tj ET
BT /F1 11.2 Tf 56 440.2259999999998 Td (Reivindicación 4. Procedimiento implementado por ordenador para asistir en la) Tj ET
BT /F1 11.2 Tf 56 425.88999999999976 Td (localización y suministro de medicamentos, caracterizado porque comprende escanear una) Tj ET
BT /F1 11.2 Tf 56 411.55399999999975 Td (tarjeta sanitaria individual; verificar la autorización del usuario; derivar una) Tj ET
BT /F1 11.2 Tf 56 397.21799999999973 Td (necesidad terapéutica o referencia de medicamento; consultar stock de farmacias) Tj ET
BT /F1 11.2 Tf 56 382.8819999999997 Td (geolocalizadas; comparar la necesidad con el stock disponible; y generar una acción) Tj ET
BT /F1 11.2 Tf 56 368.5459999999997 Td (técnica de reserva, aviso, pedido o derivación cuando no exista disponibilidad) Tj ET
BT /F1 11.2 Tf 56 354.2099999999997 Td (suficiente.) Tj ET
BT /F1 11.2 Tf 56 337.8739999999997 Td (Reivindicación 5. Dispositivo móvil, terminal o sistema de lectura que comprende una) Tj ET
BT /F1 11.2 Tf 56 323.53799999999967 Td (cámara o lector óptico y que está configurado para ejecutar el sistema de cualquiera) Tj ET
BT /F1 11.2 Tf 56 309.20199999999966 Td (de las reivindicaciones anteriores.) Tj ET
BT /F1 9 Tf 495.28 24 Td (Pagina 5) Tj ET
endstream
endobj
28 0 obj
<</Type /Font /Subtype /Type1 /BaseFont /Times-Roman /Encoding /WinAnsiEncoding >>
endobj
29 0 obj
<</Type /Font /Subtype /Type1 /BaseFont /Times-Bold /Encoding /WinAnsiEncoding >>
endobj
30 0 obj
<</Type /Font /Subtype /Type1 /BaseFont /Times-Italic /Encoding /WinAnsiEncoding >>
endobj
36 0 obj
<</Length 4216 >> stream
BT /F2 13 Tf 56 781.89 Td (9. Aplicación industrial) Tj ET
0.2 w 56 761.25 m 539.28 761.25 l S
BT /F1 11.5 Tf 56 751.25 Td (La invención es susceptible de aplicación industrial en plataformas de asistencia) Tj ET
BT /F1 11.5 Tf 56 736.53 Td (farmacéutica, sistemas de gestión de inventario, servicios de reserva o pedido de) Tj ET
BT /F1 11.5 Tf 56 721.81 Td (medicamentos y terminales de consulta sanitaria.) Tj ET
BT /F2 13 Tf 56 703.0899999999999 Td (10. Resumen) Tj ET
0.2 w 56 682.4499999999999 m 539.28 682.4499999999999 l S
BT /F1 11.5 Tf 56 672.4499999999999 Td (La presente invención se refiere a un sistema y procedimiento implementados por) Tj ET
BT /F1 11.5 Tf 56 657.7299999999999 Td (ordenador que permiten, a partir del escaneo de una tarjeta sanitaria individual) Tj ET
BT /F1 11.5 Tf 56 643.0099999999999 Td (mediante un terminal móvil o dispositivo equivalente, asociar de forma segura un) Tj ET
BT /F1 11.5 Tf 56 628.2899999999998 Td (contexto de paciente a una necesidad terapéutica, obtener una referencia de) Tj ET
BT /F1 11.5 Tf 56 613.5699999999998 Td (medicamentos relevantes, comparar dicha necesidad con el stock disponible en) Tj ET
BT /F1 11.5 Tf 56 598.8499999999998 Td (farmacias geolocalizadas y generar, en caso de falta de disponibilidad, una acción) Tj ET
BT /F1 11.5 Tf 56 584.1299999999998 Td (de reserva, aviso, solicitud de pedido o derivación a una farmacia alternativa. El) Tj ET
BT /F1 11.5 Tf 56 569.4099999999997 Td (sistema integra captura de datos, verificación de identidad, geolocalización,) Tj ET
BT /F1 11.5 Tf 56 554.6899999999997 Td (consulta de disponibilidad, gestión de stock y generación de eventos de suministro) Tj ET
BT /F1 11.5 Tf 56 539.9699999999997 Td (en una única cadena técnica de procesamiento.) Tj ET
BT /F2 13 Tf 56 521.2499999999997 Td (11. Anexo de dibujos) Tj ET
0.2 w 56 500.6099999999997 m 539.28 500.6099999999997 l S
BT /F1 11.5 Tf 56 490.6099999999997 Td (Las figuras indicadas en la presente memoria tienen carácter esquemático y se) Tj ET
BT /F1 11.5 Tf 56 475.88999999999965 Td (incorporan a efectos ilustrativos de la arquitectura, del flujo de captura, del) Tj ET
BT /F1 11.5 Tf 56 461.1699999999996 Td (contraste de stock y de la generación de acciones. En una presentación definitiva,) Tj ET
BT /F1 11.5 Tf 56 446.4499999999996 Td (dichas figuras podrán acompañarse como láminas separadas numeradas de forma) Tj ET
BT /F1 11.5 Tf 56 431.72999999999956 Td (consecutiva.) Tj ET
BT /F2 13 Tf 56 413.00999999999954 Td (12. Firma del solicitante) Tj ET
0.2 w 56 392.36999999999955 m 539.28 392.36999999999955 l S
BT /F1 11.5 Tf 56 382.36999999999955 Td (En Terrassa, a 22 de junio de 2026.) Tj ET
BT /F1 11.5 Tf 56 365.6499999999995 Td (Fdo.: Antoni Nuñez Romeu) Tj ET
BT /F1 11.5 Tf 56 348.9299999999995 Td (DNI/NIF: 45858029P) Tj ET
BT /F2 13 Tf 56 330.20999999999947 Td (13. Notas para presentación en OEPM) Tj ET
0.2 w 56 309.5699999999995 m 539.28 309.5699999999995 l S
BT /F1 11.2 Tf 64 299.5699999999995 Td (" La solicitud debe presentarse como una única invención o un único concepto inventivo) Tj ET
BT /F1 11.2 Tf 64 285.23399999999947 Td ( general.) Tj ET
BT /F1 11.2 Tf 64 270.89799999999946 Td (" La descripción, reivindicaciones y dibujos deben ser coherentes entre sí.) Tj ET
BT /F1 11.2 Tf 64 256.56199999999944 Td (" La protección en patente debe apoyarse en un efecto técnico concreto y no en una mera) Tj ET
BT /F1 11.2 Tf 64 242.22599999999943 Td ( regla comercial o método abstracto.) Tj ET
BT /F1 11.2 Tf 64 227.88999999999942 Td (" Esta redacción evita formular una reivindicación independiente de "programa de) Tj ET
BT /F1 11.2 Tf 64 213.5539999999994 Td ( ordenador como tal" y se centra en un sistema y un procedimiento implementados por) Tj ET
BT /F1 11.2 Tf 64 199.2179999999994 Td ( ordenador con interacción técnica sobre captura, contraste de inventario y generación) Tj ET
BT /F1 11.2 Tf 64 184.88199999999938 Td ( de acciones.) Tj ET
BT /F1 11.2 Tf 64 170.54599999999937 Td (" Antes de presentar la solicitud conviene una búsqueda de anterioridades y una revisión) Tj ET
BT /F1 11.2 Tf 64 156.20999999999935 Td ( por agente de propiedad industrial.) Tj ET
BT /F1 9 Tf 495.28 24 Td (Pagina 6) Tj ET
endstream
endobj
38 0 obj
<< /Type /Catalog /Pages 39 0 R>>
endobj
39 0 obj
<< /Type /Pages /Kids [ 40 0 R 41 0 R 42 0 R 43 0 R ] /Count 4 >>
endobj
40 0 obj
<< /Type /Page /Parent 39 0 R /Contents 6 0 R /Resources <</Font <</F1 1 0 R /F2 2 0 R /F3 3 0 R >> >> /MediaBox [0 0 595.28 841.89 ] /TrimBox [0 0 595.28 841.89 ] /Rotate 0 >>
endobj
41 0 obj
<< /Type /Page /Parent 39 0 R /Contents 15 0 R /Resources <</Font <</F1 9 0 R /F2 10 0 R /F3 11 0 R >> >> /MediaBox [0 0 595.28 841.89 ] /TrimBox [0 0 595.28 841.89 ] /Rotate 0 >>
endobj
42 0 obj
<< /Type /Page /Parent 39 0 R /Contents 25 0 R /Resources <</Font <</F1 18 0 R /F2 19 0 R /F3 20 0 R >> >> /MediaBox [0 0 595.28 841.89 ] /TrimBox [0 0 595.28 841.89 ] /Rotate 0 >>
endobj
43 0 obj
<< /Type /Page /Parent 39 0 R /Contents 36 0 R /Resources <</Font <</F1 28 0 R /F2 29 0 R /F3 30 0 R >> >> /MediaBox [0 0 595.28 841.89 ] /TrimBox [0 0 595.28 841.89 ] /Rotate 0 >>
endobj
xref
0 44
0000000004 65535 f
0000000015 00000 n
0000000117 00000 n
0000000218 00000 n
0000000005 00000 f
0000000007 00000 f
0000000321 00000 n
0000000008 00000 f
0000000012 00000 f
0000003815 00000 n
0000003917 00000 n
0000004019 00000 n
0000000013 00000 f
0000000014 00000 f
0000000016 00000 f
0000004123 00000 n
0000000017 00000 f
0000000021 00000 f
0000007453 00000 n
0000007556 00000 n
0000007658 00000 n
0000000022 00000 f
0000000023 00000 f
0000000024 00000 f
0000000026 00000 f
0000007762 00000 n
0000000027 00000 f
0000000031 00000 f
0000011510 00000 n
0000011613 00000 n
0000011715 00000 n
0000000032 00000 f
0000000033 00000 f
0000000034 00000 f
0000000035 00000 f
0000000037 00000 f
0000011819 00000 n
0000000000 00000 f
0000016094 00000 n
0000016144 00000 n
0000016226 00000 n
0000016425 00000 n
0000016627 00000 n
0000016830 00000 n
trailer
<</Size 22 /ID [(¦¼—¾ÄZ‚}×±<pÿ) (¦¼—¾ÄZ‚}×±<pÿ) ] /Root 38 0 R >>
startxref
17033
%%EOF
@@ -0,0 +1,53 @@
%PDF-1.4
1 0 obj
<< /Type /Font /Subtype /Type1 /BaseFont /Times-Roman /Encoding /WinAnsiEncoding >>
endobj
2 0 obj
<< /Type /Font /Subtype /Type1 /BaseFont /Times-Bold /Encoding /WinAnsiEncoding >>
endobj
3 0 obj
<< /Type /Font /Subtype /Type1 /BaseFont /Times-Italic /Encoding /WinAnsiEncoding >>
endobj
4 0 obj
<< /Type /Pages /Kids [6 0 R] /Count 1 >>
endobj
5 0 obj
<< /Length 1123 >>
stream
BT /F2 18 Tf 231 785.89 Td (Dibujos) Tj ET
BT /F3 11.5 Tf 101 763.39 Td (Bloque limpio para la presentación de la) Tj ET
BT /F3 11.5 Tf 101 749.015 Td (solicitud) Tj ET
BT /F2 13 Tf 56 716.64 Td (Breve descripción de los dibujos) Tj ET
0.3 w 56 698.39 m 539.28 698.39 l S
BT /F1 11.5 Tf 56 690.39 Td (Figura 1. Diagrama de bloques general del sistema.) Tj ET
BT /F1 11.5 Tf 56 674.015 Td (Figura 2. Flujo de captura y validación del escaneo de la TSI.) Tj ET
BT /F1 11.5 Tf 56 657.64 Td (Figura 3. Flujo de contraste entre necesidad terapéutica y stock de farmacias.) Tj ET
BT /F1 11.5 Tf 56 641.265 Td (Figura 4. Flujo de acción cuando no existe stock disponible.) Tj ET
BT /F2 13 Tf 56 612.89 Td (Nota) Tj ET
0.3 w 56 594.64 m 539.28 594.64 l S
BT /F1 11.5 Tf 56 586.64 Td (El documento fuente contiene referencias esquemáticas a las figuras, pero no) Tj ET
BT /F1 11.5 Tf 56 572.265 Td (incluye láminas técnicas dibujadas de forma separada. Si la Oficina requiere) Tj ET
BT /F1 11.5 Tf 56 557.89 Td (placas gráficas, habrá que añadirlas antes de la presentación definitiva.) Tj ET
BT /F1 9 Tf 495.28 24 Td (Page 1) Tj ET
endstream
endobj
6 0 obj
<< /Type /Page /Parent 4 0 R /MediaBox [0 0 595.28 841.89] /Resources << /Font << /F1 1 0 R /F2 2 0 R /F3 3 0 R >> >> /Contents 5 0 R >>
endobj
7 0 obj
<< /Type /Catalog /Pages 4 0 R >>
endobj
xref
0 8
0000000000 65535 f
0000000009 00000 n
0000000108 00000 n
0000000206 00000 n
0000000306 00000 n
0000000363 00000 n
0000001538 00000 n
0000001690 00000 n
trailer << /Size 8 /Root 7 0 R >>
startxref
1739
%%EOF
@@ -0,0 +1,79 @@
%PDF-1.4
%âãÏÓ
1 0 obj
<</Type /Font /Subtype /Type1 /BaseFont /Times-Roman /Encoding /WinAnsiEncoding >>
endobj
2 0 obj
<</Type /Font /Subtype /Type1 /BaseFont /Times-Bold /Encoding /WinAnsiEncoding >>
endobj
3 0 obj
<</Type /Font /Subtype /Type1 /BaseFont /Times-Italic /Encoding /WinAnsiEncoding >>
endobj
8 0 obj
<</Length 3689 >> stream
BT /F2 13 Tf 56 781.89 Td (7. Ejemplo de realización \(continuación\)) Tj ET
0.2 w 56 761.25 m 539.28 761.25 l S
BT /F1 11.2 Tf 64 751.25 Td (1. El sistema consulta farmacias cercanas con stock del medicamento.) Tj ET
BT /F1 11.2 Tf 64 736.914 Td (2. Si hay stock, ordena las farmacias por proximidad y disponibilidad.) Tj ET
BT /F1 11.2 Tf 64 722.578 Td (3. Si no hay stock, genera una reserva, una alerta o una solicitud de pedido.) Tj ET
BT /F2 13 Tf 56 706.242 Td (8. Reivindicaciones) Tj ET
0.2 w 56 685.602 m 539.28 685.602 l S
BT /F1 11.2 Tf 56 675.602 Td (Reivindicación 1. Sistema implementado por ordenador para identificar una necesidad) Tj ET
BT /F1 11.2 Tf 56 661.266 Td (terapéutica a partir del escaneo de una tarjeta sanitaria individual, caracterizado) Tj ET
BT /F1 11.2 Tf 56 646.93 Td (porque comprende medios de captura para obtener datos de la tarjeta sanitaria) Tj ET
BT /F1 11.2 Tf 56 632.5939999999999 Td (individual; medios de verificación para asociar dichos datos a un usuario autenticado;) Tj ET
BT /F1 11.2 Tf 56 618.2579999999999 Td (medios de obtención de una referencia de medicamento o necesidad terapéutica; medios) Tj ET
BT /F1 11.2 Tf 56 603.9219999999999 Td (de consulta de inventario para recuperar stock de farmacias geolocalizadas; medios de) Tj ET
BT /F1 11.2 Tf 56 589.5859999999999 Td (contraste para comparar la referencia de medicamento o necesidad terapéutica con el) Tj ET
BT /F1 11.2 Tf 56 575.2499999999999 Td (stock disponible; y medios de generación de acciones para producir una reserva, aviso,) Tj ET
BT /F1 11.2 Tf 56 560.9139999999999 Td (pedido o derivación cuando el stock disponible sea insuficiente.) Tj ET
BT /F1 11.2 Tf 56 544.5779999999999 Td (Reivindicación 2. Sistema según la reivindicación 1, en el que los medios de consulta) Tj ET
BT /F1 11.2 Tf 56 530.2419999999998 Td (de inventario ordenan las farmacias según una combinación de stock disponible,) Tj ET
BT /F1 11.2 Tf 56 515.9059999999998 Td (proximidad geográfica y horario de apertura.) Tj ET
BT /F1 11.2 Tf 56 499.5699999999998 Td (Reivindicación 3. Sistema según cualquiera de las reivindicaciones anteriores, en el) Tj ET
BT /F1 11.2 Tf 56 485.2339999999998 Td (que los datos de la tarjeta sanitaria individual son tratados mediante un módulo de) Tj ET
BT /F1 11.2 Tf 56 470.8979999999998 Td (minimización y cifrado para limitar su almacenamiento a los campos técnicamente) Tj ET
BT /F1 11.2 Tf 56 456.5619999999998 Td (necesarios.) Tj ET
BT /F1 11.2 Tf 56 440.2259999999998 Td (Reivindicación 4. Procedimiento implementado por ordenador para asistir en la) Tj ET
BT /F1 11.2 Tf 56 425.88999999999976 Td (localización y suministro de medicamentos, caracterizado porque comprende escanear una) Tj ET
BT /F1 11.2 Tf 56 411.55399999999975 Td (tarjeta sanitaria individual; verificar la autorización del usuario; derivar una) Tj ET
BT /F1 11.2 Tf 56 397.21799999999973 Td (necesidad terapéutica o referencia de medicamento; consultar stock de farmacias) Tj ET
BT /F1 11.2 Tf 56 382.8819999999997 Td (geolocalizadas; comparar la necesidad con el stock disponible; y generar una acción) Tj ET
BT /F1 11.2 Tf 56 368.5459999999997 Td (técnica de reserva, aviso, pedido o derivación cuando no exista disponibilidad) Tj ET
BT /F1 11.2 Tf 56 354.2099999999997 Td (suficiente.) Tj ET
BT /F1 11.2 Tf 56 337.8739999999997 Td (Reivindicación 5. Dispositivo móvil, terminal o sistema de lectura que comprende una) Tj ET
BT /F1 11.2 Tf 56 323.53799999999967 Td (cámara o lector óptico y que está configurado para ejecutar el sistema de cualquiera) Tj ET
BT /F1 11.2 Tf 56 309.20199999999966 Td (de las reivindicaciones anteriores.) Tj ET
BT /F1 9 Tf 495.28 24 Td (Pagina 5) Tj ET
endstream
endobj
10 0 obj
<< /Type /Catalog /Pages 11 0 R>>
endobj
11 0 obj
<< /Type /Pages /Kids [ 12 0 R ] /Count 1 >>
endobj
12 0 obj
<< /Type /Page /Parent 11 0 R /Contents 8 0 R /Resources <</Font <</F1 1 0 R /F2 2 0 R /F3 3 0 R >> >> /MediaBox [0 0 595.28 841.89 ] /TrimBox [0 0 595.28 841.89 ] /Rotate 0 >>
endobj
xref
0 13
0000000004 65535 f
0000000015 00000 n
0000000117 00000 n
0000000218 00000 n
0000000005 00000 f
0000000006 00000 f
0000000007 00000 f
0000000009 00000 f
0000000321 00000 n
0000000000 00000 f
0000004068 00000 n
0000004118 00000 n
0000004179 00000 n
trailer
<</Size 7 /ID [(ÑߊD€\\&l¹ŸÛ=�Á) (ÑߊD€\\&l¹ŸÛ=�Á) ] /Root 10 0 R >>
startxref
4378
%%EOF
@@ -0,0 +1,89 @@
%PDF-1.4
%âãÏÓ
1 0 obj
<</Type /Font /Subtype /Type1 /BaseFont /Times-Roman /Encoding /WinAnsiEncoding >>
endobj
2 0 obj
<</Type /Font /Subtype /Type1 /BaseFont /Times-Bold /Encoding /WinAnsiEncoding >>
endobj
3 0 obj
<</Type /Font /Subtype /Type1 /BaseFont /Times-Italic /Encoding /WinAnsiEncoding >>
endobj
9 0 obj
<</Length 4216 >> stream
BT /F2 13 Tf 56 781.89 Td (9. Aplicación industrial) Tj ET
0.2 w 56 761.25 m 539.28 761.25 l S
BT /F1 11.5 Tf 56 751.25 Td (La invención es susceptible de aplicación industrial en plataformas de asistencia) Tj ET
BT /F1 11.5 Tf 56 736.53 Td (farmacéutica, sistemas de gestión de inventario, servicios de reserva o pedido de) Tj ET
BT /F1 11.5 Tf 56 721.81 Td (medicamentos y terminales de consulta sanitaria.) Tj ET
BT /F2 13 Tf 56 703.0899999999999 Td (10. Resumen) Tj ET
0.2 w 56 682.4499999999999 m 539.28 682.4499999999999 l S
BT /F1 11.5 Tf 56 672.4499999999999 Td (La presente invención se refiere a un sistema y procedimiento implementados por) Tj ET
BT /F1 11.5 Tf 56 657.7299999999999 Td (ordenador que permiten, a partir del escaneo de una tarjeta sanitaria individual) Tj ET
BT /F1 11.5 Tf 56 643.0099999999999 Td (mediante un terminal móvil o dispositivo equivalente, asociar de forma segura un) Tj ET
BT /F1 11.5 Tf 56 628.2899999999998 Td (contexto de paciente a una necesidad terapéutica, obtener una referencia de) Tj ET
BT /F1 11.5 Tf 56 613.5699999999998 Td (medicamentos relevantes, comparar dicha necesidad con el stock disponible en) Tj ET
BT /F1 11.5 Tf 56 598.8499999999998 Td (farmacias geolocalizadas y generar, en caso de falta de disponibilidad, una acción) Tj ET
BT /F1 11.5 Tf 56 584.1299999999998 Td (de reserva, aviso, solicitud de pedido o derivación a una farmacia alternativa. El) Tj ET
BT /F1 11.5 Tf 56 569.4099999999997 Td (sistema integra captura de datos, verificación de identidad, geolocalización,) Tj ET
BT /F1 11.5 Tf 56 554.6899999999997 Td (consulta de disponibilidad, gestión de stock y generación de eventos de suministro) Tj ET
BT /F1 11.5 Tf 56 539.9699999999997 Td (en una única cadena técnica de procesamiento.) Tj ET
BT /F2 13 Tf 56 521.2499999999997 Td (11. Anexo de dibujos) Tj ET
0.2 w 56 500.6099999999997 m 539.28 500.6099999999997 l S
BT /F1 11.5 Tf 56 490.6099999999997 Td (Las figuras indicadas en la presente memoria tienen carácter esquemático y se) Tj ET
BT /F1 11.5 Tf 56 475.88999999999965 Td (incorporan a efectos ilustrativos de la arquitectura, del flujo de captura, del) Tj ET
BT /F1 11.5 Tf 56 461.1699999999996 Td (contraste de stock y de la generación de acciones. En una presentación definitiva,) Tj ET
BT /F1 11.5 Tf 56 446.4499999999996 Td (dichas figuras podrán acompañarse como láminas separadas numeradas de forma) Tj ET
BT /F1 11.5 Tf 56 431.72999999999956 Td (consecutiva.) Tj ET
BT /F2 13 Tf 56 413.00999999999954 Td (12. Firma del solicitante) Tj ET
0.2 w 56 392.36999999999955 m 539.28 392.36999999999955 l S
BT /F1 11.5 Tf 56 382.36999999999955 Td (En Terrassa, a 22 de junio de 2026.) Tj ET
BT /F1 11.5 Tf 56 365.6499999999995 Td (Fdo.: Antoni Nuñez Romeu) Tj ET
BT /F1 11.5 Tf 56 348.9299999999995 Td (DNI/NIF: 45858029P) Tj ET
BT /F2 13 Tf 56 330.20999999999947 Td (13. Notas para presentación en OEPM) Tj ET
0.2 w 56 309.5699999999995 m 539.28 309.5699999999995 l S
BT /F1 11.2 Tf 64 299.5699999999995 Td (" La solicitud debe presentarse como una única invención o un único concepto inventivo) Tj ET
BT /F1 11.2 Tf 64 285.23399999999947 Td ( general.) Tj ET
BT /F1 11.2 Tf 64 270.89799999999946 Td (" La descripción, reivindicaciones y dibujos deben ser coherentes entre sí.) Tj ET
BT /F1 11.2 Tf 64 256.56199999999944 Td (" La protección en patente debe apoyarse en un efecto técnico concreto y no en una mera) Tj ET
BT /F1 11.2 Tf 64 242.22599999999943 Td ( regla comercial o método abstracto.) Tj ET
BT /F1 11.2 Tf 64 227.88999999999942 Td (" Esta redacción evita formular una reivindicación independiente de "programa de) Tj ET
BT /F1 11.2 Tf 64 213.5539999999994 Td ( ordenador como tal" y se centra en un sistema y un procedimiento implementados por) Tj ET
BT /F1 11.2 Tf 64 199.2179999999994 Td ( ordenador con interacción técnica sobre captura, contraste de inventario y generación) Tj ET
BT /F1 11.2 Tf 64 184.88199999999938 Td ( de acciones.) Tj ET
BT /F1 11.2 Tf 64 170.54599999999937 Td (" Antes de presentar la solicitud conviene una búsqueda de anterioridades y una revisión) Tj ET
BT /F1 11.2 Tf 64 156.20999999999935 Td ( por agente de propiedad industrial.) Tj ET
BT /F1 9 Tf 495.28 24 Td (Pagina 6) Tj ET
endstream
endobj
11 0 obj
<< /Type /Catalog /Pages 12 0 R>>
endobj
12 0 obj
<< /Type /Pages /Kids [ 13 0 R ] /Count 1 >>
endobj
13 0 obj
<< /Type /Page /Parent 12 0 R /Contents 9 0 R /Resources <</Font <</F1 1 0 R /F2 2 0 R /F3 3 0 R >> >> /MediaBox [0 0 595.28 841.89 ] /TrimBox [0 0 595.28 841.89 ] /Rotate 0 >>
endobj
xref
0 14
0000000004 65535 f
0000000015 00000 n
0000000117 00000 n
0000000218 00000 n
0000000005 00000 f
0000000006 00000 f
0000000007 00000 f
0000000008 00000 f
0000000010 00000 f
0000000321 00000 n
0000000000 00000 f
0000004595 00000 n
0000004645 00000 n
0000004706 00000 n
trailer
<</Size 7 /ID [(†¾é^n³¼ù–ï¾xýÇ) (†¾é^n³¼ù–ï¾xýÇ) ] /Root 11 0 R >>
startxref
4905
%%EOF
@@ -0,0 +1,89 @@
%PDF-1.4
%âãÏÓ
1 0 obj
<</Type /Font /Subtype /Type1 /BaseFont /Times-Roman /Encoding /WinAnsiEncoding >>
endobj
2 0 obj
<</Type /Font /Subtype /Type1 /BaseFont /Times-Bold /Encoding /WinAnsiEncoding >>
endobj
3 0 obj
<</Type /Font /Subtype /Type1 /BaseFont /Times-Italic /Encoding /WinAnsiEncoding >>
endobj
9 0 obj
<</Length 4216 >> stream
BT /F2 13 Tf 56 781.89 Td (9. Aplicación industrial) Tj ET
0.2 w 56 761.25 m 539.28 761.25 l S
BT /F1 11.5 Tf 56 751.25 Td (La invención es susceptible de aplicación industrial en plataformas de asistencia) Tj ET
BT /F1 11.5 Tf 56 736.53 Td (farmacéutica, sistemas de gestión de inventario, servicios de reserva o pedido de) Tj ET
BT /F1 11.5 Tf 56 721.81 Td (medicamentos y terminales de consulta sanitaria.) Tj ET
BT /F2 13 Tf 56 703.0899999999999 Td (10. Resumen) Tj ET
0.2 w 56 682.4499999999999 m 539.28 682.4499999999999 l S
BT /F1 11.5 Tf 56 672.4499999999999 Td (La presente invención se refiere a un sistema y procedimiento implementados por) Tj ET
BT /F1 11.5 Tf 56 657.7299999999999 Td (ordenador que permiten, a partir del escaneo de una tarjeta sanitaria individual) Tj ET
BT /F1 11.5 Tf 56 643.0099999999999 Td (mediante un terminal móvil o dispositivo equivalente, asociar de forma segura un) Tj ET
BT /F1 11.5 Tf 56 628.2899999999998 Td (contexto de paciente a una necesidad terapéutica, obtener una referencia de) Tj ET
BT /F1 11.5 Tf 56 613.5699999999998 Td (medicamentos relevantes, comparar dicha necesidad con el stock disponible en) Tj ET
BT /F1 11.5 Tf 56 598.8499999999998 Td (farmacias geolocalizadas y generar, en caso de falta de disponibilidad, una acción) Tj ET
BT /F1 11.5 Tf 56 584.1299999999998 Td (de reserva, aviso, solicitud de pedido o derivación a una farmacia alternativa. El) Tj ET
BT /F1 11.5 Tf 56 569.4099999999997 Td (sistema integra captura de datos, verificación de identidad, geolocalización,) Tj ET
BT /F1 11.5 Tf 56 554.6899999999997 Td (consulta de disponibilidad, gestión de stock y generación de eventos de suministro) Tj ET
BT /F1 11.5 Tf 56 539.9699999999997 Td (en una única cadena técnica de procesamiento.) Tj ET
BT /F2 13 Tf 56 521.2499999999997 Td (11. Anexo de dibujos) Tj ET
0.2 w 56 500.6099999999997 m 539.28 500.6099999999997 l S
BT /F1 11.5 Tf 56 490.6099999999997 Td (Las figuras indicadas en la presente memoria tienen carácter esquemático y se) Tj ET
BT /F1 11.5 Tf 56 475.88999999999965 Td (incorporan a efectos ilustrativos de la arquitectura, del flujo de captura, del) Tj ET
BT /F1 11.5 Tf 56 461.1699999999996 Td (contraste de stock y de la generación de acciones. En una presentación definitiva,) Tj ET
BT /F1 11.5 Tf 56 446.4499999999996 Td (dichas figuras podrán acompañarse como láminas separadas numeradas de forma) Tj ET
BT /F1 11.5 Tf 56 431.72999999999956 Td (consecutiva.) Tj ET
BT /F2 13 Tf 56 413.00999999999954 Td (12. Firma del solicitante) Tj ET
0.2 w 56 392.36999999999955 m 539.28 392.36999999999955 l S
BT /F1 11.5 Tf 56 382.36999999999955 Td (En Terrassa, a 22 de junio de 2026.) Tj ET
BT /F1 11.5 Tf 56 365.6499999999995 Td (Fdo.: Antoni Nuñez Romeu) Tj ET
BT /F1 11.5 Tf 56 348.9299999999995 Td (DNI/NIF: 45858029P) Tj ET
BT /F2 13 Tf 56 330.20999999999947 Td (13. Notas para presentación en OEPM) Tj ET
0.2 w 56 309.5699999999995 m 539.28 309.5699999999995 l S
BT /F1 11.2 Tf 64 299.5699999999995 Td (" La solicitud debe presentarse como una única invención o un único concepto inventivo) Tj ET
BT /F1 11.2 Tf 64 285.23399999999947 Td ( general.) Tj ET
BT /F1 11.2 Tf 64 270.89799999999946 Td (" La descripción, reivindicaciones y dibujos deben ser coherentes entre sí.) Tj ET
BT /F1 11.2 Tf 64 256.56199999999944 Td (" La protección en patente debe apoyarse en un efecto técnico concreto y no en una mera) Tj ET
BT /F1 11.2 Tf 64 242.22599999999943 Td ( regla comercial o método abstracto.) Tj ET
BT /F1 11.2 Tf 64 227.88999999999942 Td (" Esta redacción evita formular una reivindicación independiente de "programa de) Tj ET
BT /F1 11.2 Tf 64 213.5539999999994 Td ( ordenador como tal" y se centra en un sistema y un procedimiento implementados por) Tj ET
BT /F1 11.2 Tf 64 199.2179999999994 Td ( ordenador con interacción técnica sobre captura, contraste de inventario y generación) Tj ET
BT /F1 11.2 Tf 64 184.88199999999938 Td ( de acciones.) Tj ET
BT /F1 11.2 Tf 64 170.54599999999937 Td (" Antes de presentar la solicitud conviene una búsqueda de anterioridades y una revisión) Tj ET
BT /F1 11.2 Tf 64 156.20999999999935 Td ( por agente de propiedad industrial.) Tj ET
BT /F1 9 Tf 495.28 24 Td (Pagina 6) Tj ET
endstream
endobj
11 0 obj
<< /Type /Catalog /Pages 12 0 R>>
endobj
12 0 obj
<< /Type /Pages /Kids [ 13 0 R ] /Count 1 >>
endobj
13 0 obj
<< /Type /Page /Parent 12 0 R /Contents 9 0 R /Resources <</Font <</F1 1 0 R /F2 2 0 R /F3 3 0 R >> >> /MediaBox [0 0 595.28 841.89 ] /TrimBox [0 0 595.28 841.89 ] /Rotate 0 >>
endobj
xref
0 14
0000000004 65535 f
0000000015 00000 n
0000000117 00000 n
0000000218 00000 n
0000000005 00000 f
0000000006 00000 f
0000000007 00000 f
0000000008 00000 f
0000000010 00000 f
0000000321 00000 n
0000000000 00000 f
0000004595 00000 n
0000004645 00000 n
0000004706 00000 n
trailer
<</Size 7 /ID [(9@lj²×}^Äè…Ô,ôå) (9@lj²×}^Äè…Ô,ôå) ] /Root 11 0 R >>
startxref
4905
%%EOF
+83 -4
View File
@@ -184,6 +184,44 @@ cp .env.example .env
docker compose up --build docker compose up --build
``` ```
### Production Environment Variables (IMPORTANT)
The root `.env` file is the **source of truth** for all Docker Compose services. The `docker-compose.yml` uses `${VAR:?...}` syntax which reads from this file. **Do not use placeholder values in production** — the backend validates them on startup and will crash.
Required variables to set with real secrets:
```env
# PostgreSQL password (used by postgres, backend, n8n, exporters)
PG_PASSWORD=<strong-random-hex>
# Backend session secret (required, non-placeholder)
SESSION_SECRET=<strong-random-hex>
# Backend CORS origin (must be your real domain, not localhost)
CORS_ORIGIN=https://farmacias.hacecalor.net
# N8N admin password
N8N_PASSWORD=<strong-random-string>
# Parapharmacy API keys (required for product ingestion)
INGEST_API_KEY=<strong-random-hex>
ADMIN_API_KEY=<strong-random-hex>
```
Generate secrets with:
```bash
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
```
**After changing `PG_PASSWORD`**, you must reset the PostgreSQL volume:
```bash
docker compose down
docker volume rm farmafinder_postgres_data
docker compose up -d
```
Then re-seed data (see "First Run" below).
### Services ### Services
| Service | URL | Description | | Service | URL | Description |
@@ -201,10 +239,27 @@ docker compose up --build
docker compose exec backend node create-admin.js docker compose exec backend node create-admin.js
# Default: admin / admin123 # Default: admin / admin123
# Seed sample pharmacies # Seed sample pharmacies (SQLite — for local dev)
docker compose exec backend node seed.js docker compose exec backend node seed.js
# Seed parapharmacy products (requires INGEST_API_KEY)
# The n8n-init container handles this automatically on first run.
# To re-seed manually after a PostgreSQL reset:
docker compose exec parapharmacy-api node -e "
const http = require('http');
const fs = require('fs');
const seed = JSON.parse(fs.readFileSync('/home/node/seed.json','utf8'));
const body = JSON.stringify(seed);
const req = http.request('http://localhost:3002/api/products/bulk', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-service-key': process.env.INGEST_API_KEY, 'Content-Length': Buffer.byteLength(body) }
}, res => { let d=''; res.on('data',c=>d+=c); res.on('end',()=>console.log(d)); });
req.write(body); req.end();
"
``` ```
After a PostgreSQL volume reset, the n8n-init container will re-import workflows automatically. The backend re-creates its PG tables on startup (`initDatabase()` in `server.js`).
### N8N Setup ### N8N Setup
N8N auto-creates an admin account on first start: N8N auto-creates an admin account on first start:
@@ -225,6 +280,16 @@ docker compose down
docker compose down -v docker compose down -v
``` ```
### Reset PostgreSQL Only (keep other data)
```bash
docker compose down
docker volume rm farmafinder_postgres_data
docker compose up -d
# n8n-init re-imports workflows; backend re-creates tables on startup
# Re-seed parapharmacy products (see First Run above)
```
## Manual Setup ## Manual Setup
### 1. Install Redis ### 1. Install Redis
@@ -325,13 +390,27 @@ See [Parapharmacy Documentation](docs/parapharmacy.md) for details.
## Database Schema ## Database Schema
### SQLite Tables In production (Docker), the backend uses **PostgreSQL**. In local dev without PG, it falls back to **SQLite**.
**pharmacies**: `id`, `name`, `address`, `phone`, `latitude`, `longitude` ### PostgreSQL Tables (Production)
**pharmacies**: `id`, `name`, `address`, `phone`, `latitude`, `longitude`, `opening_hours`
**pharmacy_medicines**: `id`, `pharmacy_id`, `medicine_nregistro`, `medicine_name`, `price`, `stock` **pharmacy_medicines**: `id`, `pharmacy_id`, `medicine_nregistro`, `medicine_name`, `price`, `stock`
**users**: `id`, `username`, `password_hash`, `created_at` **users**: `id`, `username`, `password_hash`, `is_admin`, `address`, `latitude`, `longitude`, `created_at`
**user_alerts**: `id`, `user_id`, `type`, `medicine_nregistro`, `title`, `detail`, `schedule`, `created_at`, `updated_at`
**push_subscriptions**: `id`, `user_id`, `medicine_nregistro`, `medicine_name`, `endpoint`, `p256dh`, `auth`, `created_at`
**push_subscriptions_pharmacy**: `id`, `user_id`, `medicine_nregistro`, `medicine_name`, `pharmacy_id`, `endpoint`, `p256dh`, `auth`, `created_at`
**expo_push_tokens**: `id`, `user_id`, `expo_token`, `created_at`
### SQLite Tables (Local Dev Fallback)
Same schema as above minus foreign key constraints and PostgreSQL-specific types.
### Redis Cache ### Redis Cache
+11
View File
@@ -24,5 +24,16 @@ EXPO_ACCESS_TOKEN=
# Genera una cadena aleatoria fuerte, p.ej.: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" # Genera una cadena aleatoria fuerte, p.ej.: node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
INGEST_API_KEY=dev-ingest-key-change-me INGEST_API_KEY=dev-ingest-key-change-me
# SMTP for password reset / forgot username emails
# Development: use Mailpit (docker) — no auth needed
SMTP_HOST=localhost
SMTP_PORT=1025
SMTP_USER=
SMTP_PASS=
SMTP_FROM=noreply@farmafinder.com
# Base URL for reset links in emails (your frontend URL)
APP_URL=http://localhost:3000
# Parapharmacy API # Parapharmacy API
PARAPHARMACY_API_URL=http://localhost:3002 PARAPHARMACY_API_URL=http://localhost:3002
+4 -2
View File
@@ -1,7 +1,9 @@
FROM node:24-alpine FROM node:slim
WORKDIR /app WORKDIR /app
COPY apps/backend/package*.json ./ COPY apps/backend/package*.json ./
RUN apk add --no-cache python3 make g++ && npm ci --omit=dev RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
RUN npm install
RUN npm ci --omit=dev
COPY apps/backend/ . COPY apps/backend/ .
COPY apps/API/ /API/ COPY apps/API/ /API/
RUN mkdir -p /app/data RUN mkdir -p /app/data
+158
View File
@@ -0,0 +1,158 @@
import { isOpenNow, isAlwaysOpen } from '../src/hours.js';
const HOURS_24_7 = {
mon: ['00:00', '24:00'],
tue: ['00:00', '24:00'],
wed: ['00:00', '24:00'],
thu: ['00:00', '24:00'],
fri: ['00:00', '24:00'],
sat: ['00:00', '24:00'],
sun: ['00:00', '24:00'],
};
const HOURS_NORMAL_WEEK = {
mon: ['09:00', '21:00'],
tue: ['09:00', '21:00'],
wed: ['09:00', '21:00'],
thu: ['09:00', '21:00'],
fri: ['09:00', '21:00'],
sat: ['09:00', '14:00'],
sun: null,
};
const HOURS_ALL_CLOSED = {
mon: null, tue: null, wed: null, thu: null, fri: null, sat: null, sun: null,
};
const HOURS_MIDNIGHT_CROSS = {
mon: ['22:00', '02:00'],
tue: ['09:00', '21:00'],
wed: null, thu: null, fri: null, sat: null, sun: null,
};
const at = (iso) => new Date(iso);
describe('isAlwaysOpen', () => {
test('all 7 days 00:00-24:00 → true', () => {
expect(isAlwaysOpen(HOURS_24_7)).toBe(true);
});
test('accepts JSON string', () => {
expect(isAlwaysOpen(JSON.stringify(HOURS_24_7))).toBe(true);
});
test('one day different → false', () => {
expect(isAlwaysOpen({ ...HOURS_24_7, sun: ['00:00', '23:59'] })).toBe(false);
});
test('one day null → false', () => {
expect(isAlwaysOpen({ ...HOURS_24_7, sun: null })).toBe(false);
});
test('null input → false', () => {
expect(isAlwaysOpen(null)).toBe(false);
expect(isAlwaysOpen('')).toBe(false);
expect(isAlwaysOpen(undefined)).toBe(false);
});
test('malformed JSON → false', () => {
expect(isAlwaysOpen('{not-json')).toBe(false);
});
});
describe('isOpenNow', () => {
test('null/empty input → null', () => {
expect(isOpenNow(null)).toBeNull();
expect(isOpenNow('')).toBeNull();
expect(isOpenNow(undefined)).toBeNull();
});
test('malformed JSON → null', () => {
expect(isOpenNow('{garbage')).toBeNull();
});
test('24/7 → { isOpen: true, kind: "24h" }', () => {
const s = isOpenNow(HOURS_24_7, at('2026-07-27T10:00:00'));
expect(s).toEqual({ isOpen: true, kind: '24h' });
});
test('24/7 at midnight → still 24h', () => {
const s = isOpenNow(HOURS_24_7, at('2026-07-27T00:00:00'));
expect(s).toEqual({ isOpen: true, kind: '24h' });
});
test('24/7 (JSON string) → kind 24h', () => {
const s = isOpenNow(JSON.stringify(HOURS_24_7), at('2026-07-27T15:00:00'));
expect(s).toEqual({ isOpen: true, kind: '24h' });
});
test('normal weekday at 10:00 (monday) → open, closesAt 21:00', () => {
// 2026-07-27 is a Monday (verify with date -d)
const s = isOpenNow(HOURS_NORMAL_WEEK, at('2026-07-27T10:30:00'));
expect(s).toEqual({ isOpen: true, kind: 'open', closesAt: '21:00' });
});
test('normal weekday at 21:30 (monday) → closed, nextOpen tuesday 09:00', () => {
const s = isOpenNow(HOURS_NORMAL_WEEK, at('2026-07-27T21:30:00'));
expect(s.isOpen).toBe(false);
expect(s.kind).toBe('after-close');
expect(s.nextOpen).toEqual({ day: 'tue', time: '09:00' });
});
test('normal weekday at 08:30 (monday) → closed before-open, opensAt 09:00', () => {
const s = isOpenNow(HOURS_NORMAL_WEEK, at('2026-07-27T08:30:00'));
expect(s).toEqual({ isOpen: false, kind: 'before-open', opensAt: '09:00', nextOpen: null });
});
test('sunday with no hours → closed, nextOpen monday 09:00', () => {
// 2026-07-26 is a Sunday
const s = isOpenNow(HOURS_NORMAL_WEEK, at('2026-07-26T10:00:00'));
expect(s.isOpen).toBe(false);
expect(s.kind).toBe('closed');
expect(s.nextOpen).toEqual({ day: 'mon', time: '09:00' });
});
test('all week closed → closed, no nextOpen', () => {
const s = isOpenNow(HOURS_ALL_CLOSED, at('2026-07-27T10:00:00'));
expect(s).toEqual({ isOpen: false, kind: 'closed', nextOpen: null });
});
test('midnight-crossing: monday 22:00-02:00, at 23:00 → open', () => {
const s = isOpenNow(HOURS_MIDNIGHT_CROSS, at('2026-07-27T23:00:00'));
expect(s.isOpen).toBe(true);
expect(s.kind).toBe('open');
expect(s.closesAt).toBe('02:00');
});
test('midnight-crossing: monday 22:00-02:00, at 01:00 → open (yesterday range still active)', () => {
// Tuesday 01:00 → it's inside monday's 22:00-02:00 range (extended past midnight)
const s = isOpenNow(HOURS_MIDNIGHT_CROSS, at('2026-07-28T01:00:00'));
expect(s.isOpen).toBe(true);
expect(s.kind).toBe('open');
});
test('midnight-crossing: at tuesday 03:00, today opens at 09:00 → before-open', () => {
// Tuesday 03:00 with monday 22:00-02:00 already finished; today's tue is 09:00-21:00.
const s = isOpenNow(HOURS_MIDNIGHT_CROSS, at('2026-07-28T03:00:00'));
expect(s).toEqual({ isOpen: false, kind: 'before-open', opensAt: '09:00', nextOpen: null });
});
test('midnight-crossing: same day before today opens → before-open with today time', () => {
// HOURS_MIDNIGHT_CROSS: mon 22:00-02:00 (crossing). At monday 10:00, before today's 22:00.
const s = isOpenNow(HOURS_MIDNIGHT_CROSS, at('2026-07-27T10:00:00'));
expect(s).toEqual({ isOpen: false, kind: 'before-open', opensAt: '22:00', nextOpen: null });
});
test('saturday morning at 10:00 (with 09:00-14:00) → open, closesAt 14:00', () => {
// 2026-07-25 is a Saturday
const s = isOpenNow(HOURS_NORMAL_WEEK, at('2026-07-25T10:00:00'));
expect(s).toEqual({ isOpen: true, kind: 'open', closesAt: '14:00' });
});
test('saturday at 15:00 → closed, nextOpen monday 09:00', () => {
const s = isOpenNow(HOURS_NORMAL_WEEK, at('2026-07-25T15:00:00'));
expect(s.isOpen).toBe(false);
expect(s.kind).toBe('after-close');
expect(s.nextOpen).toEqual({ day: 'mon', time: '09:00' });
});
});
@@ -0,0 +1,105 @@
import { jest } from '@jest/globals'
jest.unstable_mockModule('../cima-service.js', () => ({
searchMedicines: jest.fn(async () => []),
getMedicineDetails: jest.fn(async () => null),
searchOTC: jest.fn(async () => []),
}))
jest.unstable_mockModule('../farmacias-webhook-import.js', () => ({
runFarmaciaWebhookImport: jest.fn(async () => ({})),
DEFAULT_FARMACIAS_WEBHOOK: '',
importPharmaciesFromRows: jest.fn(async () => ({})),
}))
jest.unstable_mockModule('../../API/index.js', () => ({
fetchPharmaciesExternal: jest.fn(async () => []),
}))
process.env.DATABASE_PATH = ':memory:'
process.env.NODE_ENV = 'test'
const { default: supertest } = await import('supertest')
const { app, initDatabase, db } = await import('../server.js')
const HOURS_24_7 = JSON.stringify({
mon: ['00:00', '24:00'],
tue: ['00:00', '24:00'],
wed: ['00:00', '24:00'],
thu: ['00:00', '24:00'],
fri: ['00:00', '24:00'],
sat: ['00:00', '24:00'],
sun: ['00:00', '24:00'],
})
const HOURS_NORMAL = JSON.stringify({
mon: ['09:00', '21:00'],
tue: ['09:00', '21:00'],
wed: ['09:00', '21:00'],
thu: ['09:00', '21:00'],
fri: ['09:00', '21:00'],
sat: ['09:00', '14:00'],
sun: null,
})
function insertPharmacy(name, openingHours) {
return new Promise((resolve, reject) => {
db.run(
'INSERT INTO pharmacies (name, address, latitude, longitude, opening_hours) VALUES (?, ?, ?, ?, ?)',
[name, 'Address of ' + name, 41.5, 2.0, openingHours],
function (err) { return err ? reject(err) : resolve(this.lastID) }
)
})
}
beforeAll(async () => {
await initDatabase()
})
afterEach(async () => {
await new Promise((resolve, reject) => {
db.run('DELETE FROM pharmacies', (err) => (err ? reject(err) : resolve()))
})
})
describe('GET /api/pharmacies — is_open / is_24h enrichment', () => {
test('24/7 pharmacy → is_open=true, is_24h=true', async () => {
await insertPharmacy('24h Pharmacy', HOURS_24_7)
const res = await supertest(app).get('/api/pharmacies')
expect(res.status).toBe(200)
expect(res.body.length).toBe(1)
expect(res.body[0].is_open).toBe(true)
expect(res.body[0].is_24h).toBe(true)
})
test('normal hours pharmacy at an open time → is_open=true, is_24h=false', async () => {
await insertPharmacy('Normal Pharmacy', HOURS_NORMAL)
const res = await supertest(app).get('/api/pharmacies')
expect(res.status).toBe(200)
expect(res.body[0].is_24h).toBe(false)
expect(typeof res.body[0].is_open).toBe('boolean')
})
test('null opening_hours → is_open=null, is_24h=false', async () => {
await insertPharmacy('No Hours', null)
const res = await supertest(app).get('/api/pharmacies')
expect(res.status).toBe(200)
expect(res.body[0].is_open).toBeNull()
expect(res.body[0].is_24h).toBe(false)
})
test('multiple pharmacies each get correct enrichment', async () => {
await insertPharmacy('24h Pharmacy', HOURS_24_7)
await insertPharmacy('Normal Pharmacy', HOURS_NORMAL)
await insertPharmacy('No Hours', null)
const res = await supertest(app).get('/api/pharmacies')
expect(res.status).toBe(200)
expect(res.body.length).toBe(3)
const byName = Object.fromEntries(res.body.map(p => [p.name, p]))
expect(byName['24h Pharmacy'].is_open).toBe(true)
expect(byName['24h Pharmacy'].is_24h).toBe(true)
expect(byName['Normal Pharmacy'].is_24h).toBe(false)
expect(byName['No Hours'].is_open).toBeNull()
expect(byName['No Hours'].is_24h).toBe(false)
})
})
@@ -0,0 +1,30 @@
import { describe, expect, test } from '@jest/globals'
import { validateProductionEnv } from '../src/config/required-env.js'
describe('validateProductionEnv', () => {
test('rejects a missing production session secret', () => {
expect(() => validateProductionEnv({
NODE_ENV: 'production',
CORS_ORIGIN: 'https://app.example.com',
PG_URL: 'postgres://app:password@db/app',
})).toThrow(/SESSION_SECRET/i)
})
test('rejects placeholder production configuration', () => {
expect(() => validateProductionEnv({
NODE_ENV: 'production',
SESSION_SECRET: 'farma-clic-secret-key-change-in-production',
CORS_ORIGIN: 'http://localhost:3000',
PG_URL: 'postgres://app:password@db/app',
})).toThrow(/placeholder|production/i)
})
test('accepts complete non-placeholder production configuration', () => {
expect(() => validateProductionEnv({
NODE_ENV: 'production',
SESSION_SECRET: 'a-test-only-long-session-secret',
CORS_ORIGIN: 'https://app.example.com',
PG_URL: 'postgres://app:password@db/app',
})).not.toThrow()
})
})
+65
View File
@@ -0,0 +1,65 @@
<?xml version="1.0" encoding="UTF-8"?>
<coverage generated="1785151771806" clover="3.2.0">
<project timestamp="1785151771806" name="All files">
<metrics statements="56" coveredstatements="56" conditionals="71" coveredconditionals="64" methods="5" coveredmethods="5" elements="132" coveredelements="125" complexity="0" loc="56" ncloc="56" packages="1" files="1" classes="1"/>
<file name="hours.js" path="/home/f80ans0/Projects/Webs/FarmaFinder/apps/backend/src/hours.js">
<metrics statements="56" coveredstatements="56" conditionals="71" coveredconditionals="64" methods="5" coveredmethods="5"/>
<line num="15" count="1" type="stmt"/>
<line num="18" count="40" type="cond" truecount="4" falsecount="0"/>
<line num="19" count="34" type="cond" truecount="2" falsecount="0"/>
<line num="20" count="4" type="stmt"/>
<line num="21" count="4" type="stmt"/>
<line num="23" count="2" type="stmt"/>
<line num="28" count="26" type="cond" truecount="1" falsecount="1"/>
<line num="29" count="26" type="stmt"/>
<line num="30" count="26" type="cond" truecount="1" falsecount="1"/>
<line num="31" count="26" type="stmt"/>
<line num="32" count="26" type="stmt"/>
<line num="33" count="26" type="cond" truecount="3" falsecount="1"/>
<line num="34" count="26" type="cond" truecount="2" falsecount="2"/>
<line num="35" count="26" type="cond" truecount="5" falsecount="1"/>
<line num="36" count="26" type="stmt"/>
<line num="40" count="4" type="stmt"/>
<line num="41" count="11" type="stmt"/>
<line num="42" count="11" type="stmt"/>
<line num="43" count="11" type="cond" truecount="4" falsecount="0"/>
<line num="44" count="3" type="stmt"/>
<line num="47" count="1" type="stmt"/>
<line num="51" count="22" type="stmt"/>
<line num="52" count="22" type="cond" truecount="4" falsecount="0"/>
<line num="53" count="18" type="stmt"/>
<line num="54" count="48" type="stmt"/>
<line num="55" count="48" type="cond" truecount="4" falsecount="0"/>
<line num="56" count="36" type="cond" truecount="4" falsecount="0"/>
<line num="58" count="5" type="stmt"/>
<line num="62" count="18" type="stmt"/>
<line num="63" count="18" type="cond" truecount="2" falsecount="0"/>
<line num="65" count="14" type="cond" truecount="2" falsecount="0"/>
<line num="66" count="3" type="stmt"/>
<line num="69" count="11" type="stmt"/>
<line num="70" count="11" type="stmt"/>
<line num="72" count="11" type="cond" truecount="4" falsecount="0"/>
<line num="73" count="2" type="stmt"/>
<line num="76" count="9" type="stmt"/>
<line num="77" count="9" type="stmt"/>
<line num="78" count="9" type="cond" truecount="3" falsecount="1"/>
<line num="80" count="9" type="stmt"/>
<line num="84" count="9" type="stmt"/>
<line num="85" count="9" type="stmt"/>
<line num="86" count="9" type="cond" truecount="4" falsecount="0"/>
<line num="87" count="4" type="stmt"/>
<line num="88" count="4" type="stmt"/>
<line num="89" count="4" type="cond" truecount="6" falsecount="0"/>
<line num="90" count="1" type="stmt"/>
<line num="95" count="8" type="cond" truecount="2" falsecount="0"/>
<line num="96" count="2" type="cond" truecount="2" falsecount="0"/>
<line num="97" count="1" type="stmt"/>
<line num="99" count="1" type="stmt"/>
<line num="102" count="6" type="cond" truecount="2" falsecount="0"/>
<line num="103" count="2" type="stmt"/>
<line num="105" count="4" type="cond" truecount="2" falsecount="0"/>
<line num="106" count="2" type="stmt"/>
<line num="108" count="2" type="stmt"/>
</file>
</project>
</coverage>
File diff suppressed because one or more lines are too long
+224
View File
@@ -0,0 +1,224 @@
body, html {
margin:0; padding: 0;
height: 100%;
}
body {
font-family: Helvetica Neue, Helvetica, Arial;
font-size: 14px;
color:#333;
}
.small { font-size: 12px; }
*, *:after, *:before {
-webkit-box-sizing:border-box;
-moz-box-sizing:border-box;
box-sizing:border-box;
}
h1 { font-size: 20px; margin: 0;}
h2 { font-size: 14px; }
pre {
font: 12px/1.4 Consolas, "Liberation Mono", Menlo, Courier, monospace;
margin: 0;
padding: 0;
-moz-tab-size: 2;
-o-tab-size: 2;
tab-size: 2;
}
a { color:#0074D9; text-decoration:none; }
a:hover { text-decoration:underline; }
.strong { font-weight: bold; }
.space-top1 { padding: 10px 0 0 0; }
.pad2y { padding: 20px 0; }
.pad1y { padding: 10px 0; }
.pad2x { padding: 0 20px; }
.pad2 { padding: 20px; }
.pad1 { padding: 10px; }
.space-left2 { padding-left:55px; }
.space-right2 { padding-right:20px; }
.center { text-align:center; }
.clearfix { display:block; }
.clearfix:after {
content:'';
display:block;
height:0;
clear:both;
visibility:hidden;
}
.fl { float: left; }
@media only screen and (max-width:640px) {
.col3 { width:100%; max-width:100%; }
.hide-mobile { display:none!important; }
}
.quiet {
color: #7f7f7f;
color: rgba(0,0,0,0.5);
}
.quiet a { opacity: 0.7; }
.fraction {
font-family: Consolas, 'Liberation Mono', Menlo, Courier, monospace;
font-size: 10px;
color: #555;
background: #E8E8E8;
padding: 4px 5px;
border-radius: 3px;
vertical-align: middle;
}
div.path a:link, div.path a:visited { color: #333; }
table.coverage {
border-collapse: collapse;
margin: 10px 0 0 0;
padding: 0;
}
table.coverage td {
margin: 0;
padding: 0;
vertical-align: top;
}
table.coverage td.line-count {
text-align: right;
padding: 0 5px 0 20px;
}
table.coverage td.line-coverage {
text-align: right;
padding-right: 10px;
min-width:20px;
}
table.coverage td span.cline-any {
display: inline-block;
padding: 0 5px;
width: 100%;
}
.missing-if-branch {
display: inline-block;
margin-right: 5px;
border-radius: 3px;
position: relative;
padding: 0 4px;
background: #333;
color: yellow;
}
.skip-if-branch {
display: none;
margin-right: 10px;
position: relative;
padding: 0 4px;
background: #ccc;
color: white;
}
.missing-if-branch .typ, .skip-if-branch .typ {
color: inherit !important;
}
.coverage-summary {
border-collapse: collapse;
width: 100%;
}
.coverage-summary tr { border-bottom: 1px solid #bbb; }
.keyline-all { border: 1px solid #ddd; }
.coverage-summary td, .coverage-summary th { padding: 10px; }
.coverage-summary tbody { border: 1px solid #bbb; }
.coverage-summary td { border-right: 1px solid #bbb; }
.coverage-summary td:last-child { border-right: none; }
.coverage-summary th {
text-align: left;
font-weight: normal;
white-space: nowrap;
}
.coverage-summary th.file { border-right: none !important; }
.coverage-summary th.pct { }
.coverage-summary th.pic,
.coverage-summary th.abs,
.coverage-summary td.pct,
.coverage-summary td.abs { text-align: right; }
.coverage-summary td.file { white-space: nowrap; }
.coverage-summary td.pic { min-width: 120px !important; }
.coverage-summary tfoot td { }
.coverage-summary .sorter {
height: 10px;
width: 7px;
display: inline-block;
margin-left: 0.5em;
background: url(sort-arrow-sprite.png) no-repeat scroll 0 0 transparent;
}
.coverage-summary .sorted .sorter {
background-position: 0 -20px;
}
.coverage-summary .sorted-desc .sorter {
background-position: 0 -10px;
}
.status-line { height: 10px; }
/* yellow */
.cbranch-no { background: yellow !important; color: #111; }
/* dark red */
.red.solid, .status-line.low, .low .cover-fill { background:#C21F39 }
.low .chart { border:1px solid #C21F39 }
.highlighted,
.highlighted .cstat-no, .highlighted .fstat-no, .highlighted .cbranch-no{
background: #C21F39 !important;
}
/* medium red */
.cstat-no, .fstat-no, .cbranch-no, .cbranch-no { background:#F6C6CE }
/* light red */
.low, .cline-no { background:#FCE1E5 }
/* light green */
.high, .cline-yes { background:rgb(230,245,208) }
/* medium green */
.cstat-yes { background:rgb(161,215,106) }
/* dark green */
.status-line.high, .high .cover-fill { background:rgb(77,146,33) }
.high .chart { border:1px solid rgb(77,146,33) }
/* dark yellow (gold) */
.status-line.medium, .medium .cover-fill { background: #f9cd0b; }
.medium .chart { border:1px solid #f9cd0b; }
/* light yellow */
.medium { background: #fff4c2; }
.cstat-skip { background: #ddd; color: #111; }
.fstat-skip { background: #ddd; color: #111 !important; }
.cbranch-skip { background: #ddd !important; color: #111; }
span.cline-neutral { background: #eaeaea; }
.coverage-summary td.empty {
opacity: .5;
padding-top: 4px;
padding-bottom: 4px;
line-height: 1;
color: #888;
}
.cover-fill, .cover-empty {
display:inline-block;
height: 12px;
}
.chart {
line-height: 0;
}
.cover-empty {
background: white;
}
.cover-full {
border-right: none !important;
}
pre.prettyprint {
border: none !important;
padding: 0 !important;
margin: 0 !important;
}
.com { color: #999 !important; }
.ignore-none { color: #999; font-weight: normal; }
.wrapper {
min-height: 100%;
height: auto !important;
height: 100%;
margin: 0 auto -48px;
}
.footer, .push {
height: 48px;
}
@@ -0,0 +1,87 @@
/* eslint-disable */
var jumpToCode = (function init() {
// Classes of code we would like to highlight in the file view
var missingCoverageClasses = ['.cbranch-no', '.cstat-no', '.fstat-no'];
// Elements to highlight in the file listing view
var fileListingElements = ['td.pct.low'];
// We don't want to select elements that are direct descendants of another match
var notSelector = ':not(' + missingCoverageClasses.join('):not(') + ') > '; // becomes `:not(a):not(b) > `
// Selector that finds elements on the page to which we can jump
var selector =
fileListingElements.join(', ') +
', ' +
notSelector +
missingCoverageClasses.join(', ' + notSelector); // becomes `:not(a):not(b) > a, :not(a):not(b) > b`
// The NodeList of matching elements
var missingCoverageElements = document.querySelectorAll(selector);
var currentIndex;
function toggleClass(index) {
missingCoverageElements
.item(currentIndex)
.classList.remove('highlighted');
missingCoverageElements.item(index).classList.add('highlighted');
}
function makeCurrent(index) {
toggleClass(index);
currentIndex = index;
missingCoverageElements.item(index).scrollIntoView({
behavior: 'smooth',
block: 'center',
inline: 'center'
});
}
function goToPrevious() {
var nextIndex = 0;
if (typeof currentIndex !== 'number' || currentIndex === 0) {
nextIndex = missingCoverageElements.length - 1;
} else if (missingCoverageElements.length > 1) {
nextIndex = currentIndex - 1;
}
makeCurrent(nextIndex);
}
function goToNext() {
var nextIndex = 0;
if (
typeof currentIndex === 'number' &&
currentIndex < missingCoverageElements.length - 1
) {
nextIndex = currentIndex + 1;
}
makeCurrent(nextIndex);
}
return function jump(event) {
if (
document.getElementById('fileSearch') === document.activeElement &&
document.activeElement != null
) {
// if we're currently focused on the search input, we don't want to navigate
return;
}
switch (event.which) {
case 78: // n
case 74: // j
goToNext();
break;
case 66: // b
case 75: // k
case 80: // p
goToPrevious();
break;
}
};
})();
window.addEventListener('keydown', jumpToCode);
Binary file not shown.

After

Width:  |  Height:  |  Size: 445 B

@@ -0,0 +1,412 @@
<!doctype html>
<html lang="en">
<head>
<title>Code coverage report for hours.js</title>
<meta charset="utf-8" />
<link rel="stylesheet" href="prettify.css" />
<link rel="stylesheet" href="base.css" />
<link rel="shortcut icon" type="image/x-icon" href="favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style type='text/css'>
.coverage-summary .sorter {
background-image: url(sort-arrow-sprite.png);
}
</style>
</head>
<body>
<div class='wrapper'>
<div class='pad1'>
<h1><a href="index.html">All files</a> hours.js</h1>
<div class='clearfix'>
<div class='fl pad1y space-right2'>
<span class="strong">91.3% </span>
<span class="quiet">Statements</span>
<span class='fraction'>63/69</span>
</div>
<div class='fl pad1y space-right2'>
<span class="strong">90.14% </span>
<span class="quiet">Branches</span>
<span class='fraction'>64/71</span>
</div>
<div class='fl pad1y space-right2'>
<span class="strong">100% </span>
<span class="quiet">Functions</span>
<span class='fraction'>5/5</span>
</div>
<div class='fl pad1y space-right2'>
<span class="strong">100% </span>
<span class="quiet">Lines</span>
<span class='fraction'>56/56</span>
</div>
</div>
<p class="quiet">
Press <em>n</em> or <em>j</em> to go to the next uncovered block, <em>b</em>, <em>p</em> or <em>k</em> for the previous block.
</p>
<template id="filterTemplate">
<div class="quiet">
Filter:
<input type="search" id="fileSearch">
</div>
</template>
</div>
<div class='status-line high'></div>
<pre><table class="coverage">
<tr><td class="line-count quiet"><a name='L1'></a><a href='#L1'>1</a>
<a name='L2'></a><a href='#L2'>2</a>
<a name='L3'></a><a href='#L3'>3</a>
<a name='L4'></a><a href='#L4'>4</a>
<a name='L5'></a><a href='#L5'>5</a>
<a name='L6'></a><a href='#L6'>6</a>
<a name='L7'></a><a href='#L7'>7</a>
<a name='L8'></a><a href='#L8'>8</a>
<a name='L9'></a><a href='#L9'>9</a>
<a name='L10'></a><a href='#L10'>10</a>
<a name='L11'></a><a href='#L11'>11</a>
<a name='L12'></a><a href='#L12'>12</a>
<a name='L13'></a><a href='#L13'>13</a>
<a name='L14'></a><a href='#L14'>14</a>
<a name='L15'></a><a href='#L15'>15</a>
<a name='L16'></a><a href='#L16'>16</a>
<a name='L17'></a><a href='#L17'>17</a>
<a name='L18'></a><a href='#L18'>18</a>
<a name='L19'></a><a href='#L19'>19</a>
<a name='L20'></a><a href='#L20'>20</a>
<a name='L21'></a><a href='#L21'>21</a>
<a name='L22'></a><a href='#L22'>22</a>
<a name='L23'></a><a href='#L23'>23</a>
<a name='L24'></a><a href='#L24'>24</a>
<a name='L25'></a><a href='#L25'>25</a>
<a name='L26'></a><a href='#L26'>26</a>
<a name='L27'></a><a href='#L27'>27</a>
<a name='L28'></a><a href='#L28'>28</a>
<a name='L29'></a><a href='#L29'>29</a>
<a name='L30'></a><a href='#L30'>30</a>
<a name='L31'></a><a href='#L31'>31</a>
<a name='L32'></a><a href='#L32'>32</a>
<a name='L33'></a><a href='#L33'>33</a>
<a name='L34'></a><a href='#L34'>34</a>
<a name='L35'></a><a href='#L35'>35</a>
<a name='L36'></a><a href='#L36'>36</a>
<a name='L37'></a><a href='#L37'>37</a>
<a name='L38'></a><a href='#L38'>38</a>
<a name='L39'></a><a href='#L39'>39</a>
<a name='L40'></a><a href='#L40'>40</a>
<a name='L41'></a><a href='#L41'>41</a>
<a name='L42'></a><a href='#L42'>42</a>
<a name='L43'></a><a href='#L43'>43</a>
<a name='L44'></a><a href='#L44'>44</a>
<a name='L45'></a><a href='#L45'>45</a>
<a name='L46'></a><a href='#L46'>46</a>
<a name='L47'></a><a href='#L47'>47</a>
<a name='L48'></a><a href='#L48'>48</a>
<a name='L49'></a><a href='#L49'>49</a>
<a name='L50'></a><a href='#L50'>50</a>
<a name='L51'></a><a href='#L51'>51</a>
<a name='L52'></a><a href='#L52'>52</a>
<a name='L53'></a><a href='#L53'>53</a>
<a name='L54'></a><a href='#L54'>54</a>
<a name='L55'></a><a href='#L55'>55</a>
<a name='L56'></a><a href='#L56'>56</a>
<a name='L57'></a><a href='#L57'>57</a>
<a name='L58'></a><a href='#L58'>58</a>
<a name='L59'></a><a href='#L59'>59</a>
<a name='L60'></a><a href='#L60'>60</a>
<a name='L61'></a><a href='#L61'>61</a>
<a name='L62'></a><a href='#L62'>62</a>
<a name='L63'></a><a href='#L63'>63</a>
<a name='L64'></a><a href='#L64'>64</a>
<a name='L65'></a><a href='#L65'>65</a>
<a name='L66'></a><a href='#L66'>66</a>
<a name='L67'></a><a href='#L67'>67</a>
<a name='L68'></a><a href='#L68'>68</a>
<a name='L69'></a><a href='#L69'>69</a>
<a name='L70'></a><a href='#L70'>70</a>
<a name='L71'></a><a href='#L71'>71</a>
<a name='L72'></a><a href='#L72'>72</a>
<a name='L73'></a><a href='#L73'>73</a>
<a name='L74'></a><a href='#L74'>74</a>
<a name='L75'></a><a href='#L75'>75</a>
<a name='L76'></a><a href='#L76'>76</a>
<a name='L77'></a><a href='#L77'>77</a>
<a name='L78'></a><a href='#L78'>78</a>
<a name='L79'></a><a href='#L79'>79</a>
<a name='L80'></a><a href='#L80'>80</a>
<a name='L81'></a><a href='#L81'>81</a>
<a name='L82'></a><a href='#L82'>82</a>
<a name='L83'></a><a href='#L83'>83</a>
<a name='L84'></a><a href='#L84'>84</a>
<a name='L85'></a><a href='#L85'>85</a>
<a name='L86'></a><a href='#L86'>86</a>
<a name='L87'></a><a href='#L87'>87</a>
<a name='L88'></a><a href='#L88'>88</a>
<a name='L89'></a><a href='#L89'>89</a>
<a name='L90'></a><a href='#L90'>90</a>
<a name='L91'></a><a href='#L91'>91</a>
<a name='L92'></a><a href='#L92'>92</a>
<a name='L93'></a><a href='#L93'>93</a>
<a name='L94'></a><a href='#L94'>94</a>
<a name='L95'></a><a href='#L95'>95</a>
<a name='L96'></a><a href='#L96'>96</a>
<a name='L97'></a><a href='#L97'>97</a>
<a name='L98'></a><a href='#L98'>98</a>
<a name='L99'></a><a href='#L99'>99</a>
<a name='L100'></a><a href='#L100'>100</a>
<a name='L101'></a><a href='#L101'>101</a>
<a name='L102'></a><a href='#L102'>102</a>
<a name='L103'></a><a href='#L103'>103</a>
<a name='L104'></a><a href='#L104'>104</a>
<a name='L105'></a><a href='#L105'>105</a>
<a name='L106'></a><a href='#L106'>106</a>
<a name='L107'></a><a href='#L107'>107</a>
<a name='L108'></a><a href='#L108'>108</a>
<a name='L109'></a><a href='#L109'>109</a>
<a name='L110'></a><a href='#L110'>110</a></td><td class="line-coverage quiet"><span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">1x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">40x</span>
<span class="cline-any cline-yes">34x</span>
<span class="cline-any cline-yes">4x</span>
<span class="cline-any cline-yes">4x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">2x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">26x</span>
<span class="cline-any cline-yes">26x</span>
<span class="cline-any cline-yes">26x</span>
<span class="cline-any cline-yes">26x</span>
<span class="cline-any cline-yes">26x</span>
<span class="cline-any cline-yes">26x</span>
<span class="cline-any cline-yes">26x</span>
<span class="cline-any cline-yes">26x</span>
<span class="cline-any cline-yes">26x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">4x</span>
<span class="cline-any cline-yes">11x</span>
<span class="cline-any cline-yes">11x</span>
<span class="cline-any cline-yes">11x</span>
<span class="cline-any cline-yes">3x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">1x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">22x</span>
<span class="cline-any cline-yes">22x</span>
<span class="cline-any cline-yes">18x</span>
<span class="cline-any cline-yes">48x</span>
<span class="cline-any cline-yes">48x</span>
<span class="cline-any cline-yes">36x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">5x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">18x</span>
<span class="cline-any cline-yes">18x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">14x</span>
<span class="cline-any cline-yes">3x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">11x</span>
<span class="cline-any cline-yes">11x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">11x</span>
<span class="cline-any cline-yes">2x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">9x</span>
<span class="cline-any cline-yes">9x</span>
<span class="cline-any cline-yes">9x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">9x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">9x</span>
<span class="cline-any cline-yes">9x</span>
<span class="cline-any cline-yes">9x</span>
<span class="cline-any cline-yes">4x</span>
<span class="cline-any cline-yes">4x</span>
<span class="cline-any cline-yes">4x</span>
<span class="cline-any cline-yes">1x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">8x</span>
<span class="cline-any cline-yes">2x</span>
<span class="cline-any cline-yes">1x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">1x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">6x</span>
<span class="cline-any cline-yes">2x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">4x</span>
<span class="cline-any cline-yes">2x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-yes">2x</span>
<span class="cline-any cline-neutral">&nbsp;</span>
<span class="cline-any cline-neutral">&nbsp;</span></td><td class="text"><pre class="prettyprint lang-js">/**
* Compute "open now" / "always open" for a pharmacy whose opening_hours is
* stored as JSON in the { mon, tue, ..., sun } shape produced by
* apps/API/opening-hours-osm.js.
*
* 24/7 is internally represented as every day ["00:00", "24:00"]. The
* literal "24:00" is not valid HH:mm ISO; we accept it as 1440 minutes
* (00:00 of the next day) so range math works, and treat a 24h week as
* the dedicated `kind: '24h'` result.
*
* Midnight-crossing ranges (close &lt;= open) are detected by comparing
* the previous day's range when "now" is in the early hours.
*/
&nbsp;
const DAYS = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'];
&nbsp;
function parse(raw) {
if (raw == null || raw === '') return null;
if (typeof raw === 'object') return raw;
try {
return JSON.parse(raw);
} catch {
return null;
}
}
&nbsp;
function toMin(hm) {
<span class="missing-if-branch" title="if path not taken" >I</span>if (hm == null) <span class="cstat-no" title="statement not covered" >return null;</span>
const parts = String(hm).split(':');
<span class="missing-if-branch" title="if path not taken" >I</span>if (parts.length !== 2) <span class="cstat-no" title="statement not covered" >return null;</span>
const h = Number(parts[0]);
const m = Number(parts[1]);
<span class="missing-if-branch" title="if path not taken" >I</span>if (!Number.isFinite(h) || !Number.isFinite(m)) <span class="cstat-no" title="statement not covered" >return null;</span>
<span class="missing-if-branch" title="if path not taken" >I</span>if (h === 24 &amp;&amp; <span class="branch-1 cbranch-no" title="branch not covered" >m === 0)</span> <span class="cstat-no" title="statement not covered" >return 1440;</span>
<span class="missing-if-branch" title="if path not taken" >I</span>if (h &lt; 0 || h &gt; 24 || m &lt; 0 || m &gt;= 60) <span class="cstat-no" title="statement not covered" >return null;</span>
return h * 60 + m;
}
&nbsp;
function findNextOpen(h, now) {
for (let off = 1; off &lt;= 7; off++) {
const key = DAYS[(now.getDay() + off) % 7];
const r = h[key];
if (Array.isArray(r) &amp;&amp; r.length === 2) {
return { day: key, time: r[0] };
}
}
return null;
}
&nbsp;
export function isAlwaysOpen(rawHours) {
const h = parse(rawHours);
if (!h || typeof h !== 'object') return false;
for (const d of DAYS) {
const r = h[d];
if (!Array.isArray(r) || r.length !== 2) return false;
if (r[0] !== '00:00' || r[1] !== '24:00') return false;
}
return true;
}
&nbsp;
export function isOpenNow(rawHours, now = new Date()) {
const h = parse(rawHours);
if (!h) return null;
&nbsp;
if (isAlwaysOpen(h)) {
return { isOpen: true, kind: '24h' };
}
&nbsp;
const dayKey = DAYS[now.getDay()];
const range = h[dayKey];
&nbsp;
if (!Array.isArray(range) || range.length !== 2) {
return { isOpen: false, kind: 'closed', nextOpen: findNextOpen(h, now) };
}
&nbsp;
const openM = toMin(range[0]);
const closeM = toMin(range[1]);
<span class="missing-if-branch" title="if path not taken" >I</span>if (openM == null || closeM == null) <span class="cstat-no" title="statement not covered" >return null;</span>
&nbsp;
const nowM = now.getHours() * 60 + now.getMinutes();
&nbsp;
// First: is "now" still inside yesterday's midnight-crossing range?
// (Today is irrelevant while we're still in the previous day's after-midnight tail.)
const yestKey = DAYS[(now.getDay() + 6) % 7];
const yest = h[yestKey];
if (Array.isArray(yest) &amp;&amp; yest.length === 2) {
const yOpen = toMin(yest[0]);
const yClose = toMin(yest[1]);
if (yOpen != null &amp;&amp; yClose != null &amp;&amp; yClose &lt;= yOpen &amp;&amp; nowM &lt; yClose) {
return { isOpen: true, kind: 'open', closesAt: yest[1] };
}
}
&nbsp;
// Midnight-crossing: today's range spans past 24:00.
if (closeM &lt;= openM) {
if (nowM &gt;= openM) {
return { isOpen: true, kind: 'open', closesAt: range[1] };
}
return { isOpen: false, kind: 'before-open', opensAt: range[0], nextOpen: null };
}
&nbsp;
if (nowM &lt; openM) {
return { isOpen: false, kind: 'before-open', opensAt: range[0], nextOpen: null };
}
if (nowM &gt;= closeM) {
return { isOpen: false, kind: 'after-close', nextOpen: findNextOpen(h, now) };
}
return { isOpen: true, kind: 'open', closesAt: range[1] };
}
&nbsp;</pre></td></tr></table></pre>
<div class='push'></div><!-- for sticky footer -->
</div><!-- /wrapper -->
<div class='footer quiet pad2 space-top1 center small'>
Code coverage generated by
<a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
at 2026-07-27T11:29:31.802Z
</div>
<script src="prettify.js"></script>
<script>
window.onload = function () {
prettyPrint();
};
</script>
<script src="sorter.js"></script>
<script src="block-navigation.js"></script>
</body>
</html>
@@ -0,0 +1,116 @@
<!doctype html>
<html lang="en">
<head>
<title>Code coverage report for All files</title>
<meta charset="utf-8" />
<link rel="stylesheet" href="prettify.css" />
<link rel="stylesheet" href="base.css" />
<link rel="shortcut icon" type="image/x-icon" href="favicon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<style type='text/css'>
.coverage-summary .sorter {
background-image: url(sort-arrow-sprite.png);
}
</style>
</head>
<body>
<div class='wrapper'>
<div class='pad1'>
<h1>All files</h1>
<div class='clearfix'>
<div class='fl pad1y space-right2'>
<span class="strong">91.3% </span>
<span class="quiet">Statements</span>
<span class='fraction'>63/69</span>
</div>
<div class='fl pad1y space-right2'>
<span class="strong">90.14% </span>
<span class="quiet">Branches</span>
<span class='fraction'>64/71</span>
</div>
<div class='fl pad1y space-right2'>
<span class="strong">100% </span>
<span class="quiet">Functions</span>
<span class='fraction'>5/5</span>
</div>
<div class='fl pad1y space-right2'>
<span class="strong">100% </span>
<span class="quiet">Lines</span>
<span class='fraction'>56/56</span>
</div>
</div>
<p class="quiet">
Press <em>n</em> or <em>j</em> to go to the next uncovered block, <em>b</em>, <em>p</em> or <em>k</em> for the previous block.
</p>
<template id="filterTemplate">
<div class="quiet">
Filter:
<input type="search" id="fileSearch">
</div>
</template>
</div>
<div class='status-line high'></div>
<div class="pad1">
<table class="coverage-summary">
<thead>
<tr>
<th data-col="file" data-fmt="html" data-html="true" class="file">File</th>
<th data-col="pic" data-type="number" data-fmt="html" data-html="true" class="pic"></th>
<th data-col="statements" data-type="number" data-fmt="pct" class="pct">Statements</th>
<th data-col="statements_raw" data-type="number" data-fmt="html" class="abs"></th>
<th data-col="branches" data-type="number" data-fmt="pct" class="pct">Branches</th>
<th data-col="branches_raw" data-type="number" data-fmt="html" class="abs"></th>
<th data-col="functions" data-type="number" data-fmt="pct" class="pct">Functions</th>
<th data-col="functions_raw" data-type="number" data-fmt="html" class="abs"></th>
<th data-col="lines" data-type="number" data-fmt="pct" class="pct">Lines</th>
<th data-col="lines_raw" data-type="number" data-fmt="html" class="abs"></th>
</tr>
</thead>
<tbody><tr>
<td class="file high" data-value="hours.js"><a href="hours.js.html">hours.js</a></td>
<td data-value="91.3" class="pic high">
<div class="chart"><div class="cover-fill" style="width: 91%"></div><div class="cover-empty" style="width: 9%"></div></div>
</td>
<td data-value="91.3" class="pct high">91.3%</td>
<td data-value="69" class="abs high">63/69</td>
<td data-value="90.14" class="pct high">90.14%</td>
<td data-value="71" class="abs high">64/71</td>
<td data-value="100" class="pct high">100%</td>
<td data-value="5" class="abs high">5/5</td>
<td data-value="100" class="pct high">100%</td>
<td data-value="56" class="abs high">56/56</td>
</tr>
</tbody>
</table>
</div>
<div class='push'></div><!-- for sticky footer -->
</div><!-- /wrapper -->
<div class='footer quiet pad2 space-top1 center small'>
Code coverage generated by
<a href="https://istanbul.js.org/" target="_blank" rel="noopener noreferrer">istanbul</a>
at 2026-07-27T11:29:31.802Z
</div>
<script src="prettify.js"></script>
<script>
window.onload = function () {
prettyPrint();
};
</script>
<script src="sorter.js"></script>
<script src="block-navigation.js"></script>
</body>
</html>
@@ -0,0 +1 @@
.pln{color:#000}@media screen{.str{color:#080}.kwd{color:#008}.com{color:#800}.typ{color:#606}.lit{color:#066}.pun,.opn,.clo{color:#660}.tag{color:#008}.atn{color:#606}.atv{color:#080}.dec,.var{color:#606}.fun{color:red}}@media print,projection{.str{color:#060}.kwd{color:#006;font-weight:bold}.com{color:#600;font-style:italic}.typ{color:#404;font-weight:bold}.lit{color:#044}.pun,.opn,.clo{color:#440}.tag{color:#006;font-weight:bold}.atn{color:#404}.atv{color:#060}}pre.prettyprint{padding:2px;border:1px solid #888}ol.linenums{margin-top:0;margin-bottom:0}li.L0,li.L1,li.L2,li.L3,li.L5,li.L6,li.L7,li.L8{list-style-type:none}li.L1,li.L3,li.L5,li.L7,li.L9{background:#eee}
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 138 B

+210
View File
@@ -0,0 +1,210 @@
/* eslint-disable */
var addSorting = (function() {
'use strict';
var cols,
currentSort = {
index: 0,
desc: false
};
// returns the summary table element
function getTable() {
return document.querySelector('.coverage-summary');
}
// returns the thead element of the summary table
function getTableHeader() {
return getTable().querySelector('thead tr');
}
// returns the tbody element of the summary table
function getTableBody() {
return getTable().querySelector('tbody');
}
// returns the th element for nth column
function getNthColumn(n) {
return getTableHeader().querySelectorAll('th')[n];
}
function onFilterInput() {
const searchValue = document.getElementById('fileSearch').value;
const rows = document.getElementsByTagName('tbody')[0].children;
// Try to create a RegExp from the searchValue. If it fails (invalid regex),
// it will be treated as a plain text search
let searchRegex;
try {
searchRegex = new RegExp(searchValue, 'i'); // 'i' for case-insensitive
} catch (error) {
searchRegex = null;
}
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
let isMatch = false;
if (searchRegex) {
// If a valid regex was created, use it for matching
isMatch = searchRegex.test(row.textContent);
} else {
// Otherwise, fall back to the original plain text search
isMatch = row.textContent
.toLowerCase()
.includes(searchValue.toLowerCase());
}
row.style.display = isMatch ? '' : 'none';
}
}
// loads the search box
function addSearchBox() {
var template = document.getElementById('filterTemplate');
var templateClone = template.content.cloneNode(true);
templateClone.getElementById('fileSearch').oninput = onFilterInput;
template.parentElement.appendChild(templateClone);
}
// loads all columns
function loadColumns() {
var colNodes = getTableHeader().querySelectorAll('th'),
colNode,
cols = [],
col,
i;
for (i = 0; i < colNodes.length; i += 1) {
colNode = colNodes[i];
col = {
key: colNode.getAttribute('data-col'),
sortable: !colNode.getAttribute('data-nosort'),
type: colNode.getAttribute('data-type') || 'string'
};
cols.push(col);
if (col.sortable) {
col.defaultDescSort = col.type === 'number';
colNode.innerHTML =
colNode.innerHTML + '<span class="sorter"></span>';
}
}
return cols;
}
// attaches a data attribute to every tr element with an object
// of data values keyed by column name
function loadRowData(tableRow) {
var tableCols = tableRow.querySelectorAll('td'),
colNode,
col,
data = {},
i,
val;
for (i = 0; i < tableCols.length; i += 1) {
colNode = tableCols[i];
col = cols[i];
val = colNode.getAttribute('data-value');
if (col.type === 'number') {
val = Number(val);
}
data[col.key] = val;
}
return data;
}
// loads all row data
function loadData() {
var rows = getTableBody().querySelectorAll('tr'),
i;
for (i = 0; i < rows.length; i += 1) {
rows[i].data = loadRowData(rows[i]);
}
}
// sorts the table using the data for the ith column
function sortByIndex(index, desc) {
var key = cols[index].key,
sorter = function(a, b) {
a = a.data[key];
b = b.data[key];
return a < b ? -1 : a > b ? 1 : 0;
},
finalSorter = sorter,
tableBody = document.querySelector('.coverage-summary tbody'),
rowNodes = tableBody.querySelectorAll('tr'),
rows = [],
i;
if (desc) {
finalSorter = function(a, b) {
return -1 * sorter(a, b);
};
}
for (i = 0; i < rowNodes.length; i += 1) {
rows.push(rowNodes[i]);
tableBody.removeChild(rowNodes[i]);
}
rows.sort(finalSorter);
for (i = 0; i < rows.length; i += 1) {
tableBody.appendChild(rows[i]);
}
}
// removes sort indicators for current column being sorted
function removeSortIndicators() {
var col = getNthColumn(currentSort.index),
cls = col.className;
cls = cls.replace(/ sorted$/, '').replace(/ sorted-desc$/, '');
col.className = cls;
}
// adds sort indicators for current column being sorted
function addSortIndicators() {
getNthColumn(currentSort.index).className += currentSort.desc
? ' sorted-desc'
: ' sorted';
}
// adds event listeners for all sorter widgets
function enableUI() {
var i,
el,
ithSorter = function ithSorter(i) {
var col = cols[i];
return function() {
var desc = col.defaultDescSort;
if (currentSort.index === i) {
desc = !currentSort.desc;
}
sortByIndex(i, desc);
removeSortIndicators();
currentSort.index = i;
currentSort.desc = desc;
addSortIndicators();
};
};
for (i = 0; i < cols.length; i += 1) {
if (cols[i].sortable) {
// add the click event handler on the th so users
// dont have to click on those tiny arrows
el = getNthColumn(i).querySelector('.sorter').parentElement;
if (el.addEventListener) {
el.addEventListener('click', ithSorter(i));
} else {
el.attachEvent('onclick', ithSorter(i));
}
}
}
}
// adds sorting functionality to the UI
return function() {
if (!getTable()) {
return;
}
cols = loadColumns();
loadData();
addSearchBox();
addSortIndicators();
enableUI();
};
})();
window.addEventListener('load', addSorting);
+146
View File
@@ -0,0 +1,146 @@
TN:
SF:src/hours.js
FN:17,parse
FN:27,toMin
FN:39,findNextOpen
FN:50,isAlwaysOpen
FN:61,isOpenNow
FNF:5
FNH:5
FNDA:40,parse
FNDA:26,toMin
FNDA:4,findNextOpen
FNDA:22,isAlwaysOpen
FNDA:18,isOpenNow
DA:15,1
DA:18,40
DA:19,34
DA:20,4
DA:21,4
DA:23,2
DA:28,26
DA:29,26
DA:30,26
DA:31,26
DA:32,26
DA:33,26
DA:34,26
DA:35,26
DA:36,26
DA:40,4
DA:41,11
DA:42,11
DA:43,11
DA:44,3
DA:47,1
DA:51,22
DA:52,22
DA:53,18
DA:54,48
DA:55,48
DA:56,36
DA:58,5
DA:62,18
DA:63,18
DA:65,14
DA:66,3
DA:69,11
DA:70,11
DA:72,11
DA:73,2
DA:76,9
DA:77,9
DA:78,9
DA:80,9
DA:84,9
DA:85,9
DA:86,9
DA:87,4
DA:88,4
DA:89,4
DA:90,1
DA:95,8
DA:96,2
DA:97,1
DA:99,1
DA:102,6
DA:103,2
DA:105,4
DA:106,2
DA:108,2
LF:56
LH:56
BRDA:18,0,0,6
BRDA:18,0,1,34
BRDA:18,1,0,40
BRDA:18,1,1,36
BRDA:19,2,0,30
BRDA:19,2,1,4
BRDA:28,3,0,0
BRDA:28,3,1,26
BRDA:30,4,0,0
BRDA:30,4,1,26
BRDA:33,5,0,0
BRDA:33,5,1,26
BRDA:33,6,0,26
BRDA:33,6,1,26
BRDA:34,7,0,0
BRDA:34,7,1,26
BRDA:34,8,0,26
BRDA:34,8,1,0
BRDA:35,9,0,0
BRDA:35,9,1,26
BRDA:35,10,0,26
BRDA:35,10,1,26
BRDA:35,10,2,26
BRDA:35,10,3,26
BRDA:43,11,0,3
BRDA:43,11,1,8
BRDA:43,12,0,11
BRDA:43,12,1,3
BRDA:52,13,0,4
BRDA:52,13,1,18
BRDA:52,14,0,22
BRDA:52,14,1,18
BRDA:55,15,0,12
BRDA:55,15,1,36
BRDA:55,16,0,48
BRDA:55,16,1,36
BRDA:56,17,0,1
BRDA:56,17,1,35
BRDA:56,18,0,36
BRDA:56,18,1,36
BRDA:61,19,0,4
BRDA:63,20,0,4
BRDA:63,20,1,14
BRDA:65,21,0,3
BRDA:65,21,1,11
BRDA:72,22,0,2
BRDA:72,22,1,9
BRDA:72,23,0,11
BRDA:72,23,1,9
BRDA:78,24,0,0
BRDA:78,24,1,9
BRDA:78,25,0,9
BRDA:78,25,1,9
BRDA:86,26,0,4
BRDA:86,26,1,5
BRDA:86,27,0,9
BRDA:86,27,1,4
BRDA:89,28,0,1
BRDA:89,28,1,3
BRDA:89,29,0,4
BRDA:89,29,1,4
BRDA:89,29,2,4
BRDA:89,29,3,2
BRDA:95,30,0,2
BRDA:95,30,1,6
BRDA:96,31,0,1
BRDA:96,31,1,1
BRDA:102,32,0,2
BRDA:102,32,1,4
BRDA:105,33,0,2
BRDA:105,33,1,2
BRF:71
BRH:64
end_of_record
+2 -1
View File
@@ -40,11 +40,12 @@
"express-rate-limit": "^8.5.2", "express-rate-limit": "^8.5.2",
"express-session": "^1.17.3", "express-session": "^1.17.3",
"multer": "^2.2.0", "multer": "^2.2.0",
"nodemailer": "^6.10.1",
"pg": "^8.13.0", "pg": "^8.13.0",
"pino": "^9.4.0", "pino": "^9.4.0",
"pino-http": "^10.3.0", "pino-http": "^10.3.0",
"redis": "^4.6.0", "redis": "^4.6.0",
"sqlite3": "^5.1.6", "sqlite3": "^5.1.7",
"tesseract.js": "^7.0.0", "tesseract.js": "^7.0.0",
"web-push": "^3.6.7" "web-push": "^3.6.7"
}, },
+130 -6
View File
@@ -25,7 +25,13 @@ import pinoHttp from 'pino-http';
import multer from 'multer'; import multer from 'multer';
import { searchMedicines, getMedicineDetails, searchOTC } from './cima-service.js'; import { searchMedicines, getMedicineDetails, searchOTC } from './cima-service.js';
import { runFarmaciaWebhookImport, DEFAULT_FARMACIAS_WEBHOOK, importPharmaciesFromRows } from './farmacias-webhook-import.js'; import { runFarmaciaWebhookImport, DEFAULT_FARMACIAS_WEBHOOK, importPharmaciesFromRows } from './farmacias-webhook-import.js';
import { sendPasswordResetEmail, sendForgotUsernameEmail } from './src/email.js';
import { fetchPharmaciesExternal } from '../API/index.js'; import { fetchPharmaciesExternal } from '../API/index.js';
import { validateProductionEnv } from './src/config/required-env.js';
import { isOpenNow, isAlwaysOpen } from './src/hours.js';
import crypto from 'crypto';
validateProductionEnv();
const __filename = fileURLToPath(import.meta.url); const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename); const __dirname = path.dirname(__filename);
@@ -81,11 +87,11 @@ if (PG_URL) {
} }
const sessionConfig = { const sessionConfig = {
secret: process.env.SESSION_SECRET || 'farma-clic-secret-key-change-in-production', secret: process.env.SESSION_SECRET || (process.env.NODE_ENV === 'test' ? 'test-only-session-secret' : undefined),
resave: false, resave: false,
saveUninitialized: false, saveUninitialized: false,
cookie: { cookie: {
secure: process.env.COOKIE_SECURE === 'true', secure: process.env.COOKIE_SECURE !== 'false' && (process.env.NODE_ENV === 'production' || process.env.COOKIE_SECURE === 'true'),
sameSite: 'lax', sameSite: 'lax',
httpOnly: true, httpOnly: true,
maxAge: 24 * 60 * 60 * 1000 // 24 hours maxAge: 24 * 60 * 60 * 1000 // 24 hours
@@ -115,6 +121,7 @@ const searchLimiter = rateLimit({ windowMs: 60_000, max: 30, standardHeaders: tr
const loginLimiter = rateLimit({ windowMs: 60_000, max: 5, standardHeaders: true, legacyHeaders: false, handler: limitHandler('login') }); const loginLimiter = rateLimit({ windowMs: 60_000, max: 5, standardHeaders: true, legacyHeaders: false, handler: limitHandler('login') });
const registerLimiter = rateLimit({ windowMs: 60 * 60_000, max: 10, standardHeaders: true, legacyHeaders: false, handler: limitHandler('register') }); const registerLimiter = rateLimit({ windowMs: 60 * 60_000, max: 10, standardHeaders: true, legacyHeaders: false, handler: limitHandler('register') });
const geocodeLimiter = rateLimit({ windowMs: 60_000, max: 10, standardHeaders: true, legacyHeaders: false, handler: limitHandler('geocode') }); const geocodeLimiter = rateLimit({ windowMs: 60_000, max: 10, standardHeaders: true, legacyHeaders: false, handler: limitHandler('geocode') });
const forgotPasswordLimiter = rateLimit({ windowMs: 60_000, max: 3, standardHeaders: true, legacyHeaders: false, handler: limitHandler('forgot-password') });
const VAPID_PUBLIC_KEY = process.env.VAPID_PUBLIC_KEY || ''; const VAPID_PUBLIC_KEY = process.env.VAPID_PUBLIC_KEY || '';
const VAPID_PRIVATE_KEY = process.env.VAPID_PRIVATE_KEY || ''; const VAPID_PRIVATE_KEY = process.env.VAPID_PRIVATE_KEY || '';
@@ -223,6 +230,17 @@ function serializeOpeningHours(value) {
return null; return null;
} }
function enrichPharmacy(row) {
let isOpen = null;
let is24h = false;
if (row.opening_hours) {
const status = isOpenNow(row.opening_hours);
isOpen = status ? status.isOpen : null;
is24h = isAlwaysOpen(row.opening_hours);
}
return { ...row, is_open: isOpen, is_24h: is24h };
}
// Initialize database tables // Initialize database tables
async function initDatabase() { async function initDatabase() {
try { try {
@@ -587,6 +605,33 @@ if (!pgPool) {
) )
`); `);
} }
// Password reset tokens table
if (pgPool) {
await pgPool.query(`
CREATE TABLE IF NOT EXISTS password_reset_tokens (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token TEXT NOT NULL UNIQUE,
used INTEGER NOT NULL DEFAULT 0,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
)
`);
await pgPool.query(`CREATE INDEX IF NOT EXISTS idx_pwd_reset_token ON password_reset_tokens(token)`);
} else {
await dbRun(`
CREATE TABLE IF NOT EXISTS password_reset_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
token TEXT NOT NULL UNIQUE,
used INTEGER NOT NULL DEFAULT 0,
expires_at DATETIME NOT NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id)
)
`);
await dbRun(`CREATE INDEX IF NOT EXISTS idx_pwd_reset_token ON password_reset_tokens(token)`);
}
} catch (err) { } catch (err) {
console.error('initDatabase failed:', err); console.error('initDatabase failed:', err);
throw err; throw err;
@@ -655,7 +700,7 @@ app.get('/api/medicines/:medicineId/pharmacies', async (req, res) => {
`); `);
} }
res.json(pharmacies); res.json(pharmacies.map(enrichPharmacy));
} catch (error) { } catch (error) {
console.error('Error fetching pharmacies:', error); console.error('Error fetching pharmacies:', error);
res.status(500).json({ error: 'Internal server error' }); res.status(500).json({ error: 'Internal server error' });
@@ -820,7 +865,7 @@ app.get('/api/products/:source/:productId/pharmacies', async (req, res) => {
`); `);
} }
res.json(pharmacies); res.json(pharmacies.map(enrichPharmacy));
} catch (error) { } catch (error) {
console.error('Error fetching pharmacies for product:', error); console.error('Error fetching pharmacies for product:', error);
res.status(500).json({ error: 'Internal server error' }); res.status(500).json({ error: 'Internal server error' });
@@ -917,7 +962,7 @@ app.get('/api/pharmacies', async (req, res) => {
const pharmacies = await userDbAll(` const pharmacies = await userDbAll(`
SELECT * FROM pharmacies ORDER BY name SELECT * FROM pharmacies ORDER BY name
`); `);
res.json(pharmacies); res.json(pharmacies.map(enrichPharmacy));
} catch (error) { } catch (error) {
console.error('Error fetching pharmacies:', error); console.error('Error fetching pharmacies:', error);
res.status(500).json({ error: 'Internal server error' }); res.status(500).json({ error: 'Internal server error' });
@@ -1189,6 +1234,86 @@ app.post('/api/auth/logout', (req, res) => {
}); });
}); });
// Forgot username — send username to user's email
app.post('/api/auth/forgot-username', forgotPasswordLimiter, async (req, res) => {
try {
const { email } = req.body || {};
if (!email || !String(email).trim()) {
return res.status(400).json({ error: 'Email is required' });
}
const user = await userDbGet('SELECT username, email FROM users WHERE email = ?', [String(email).trim()]);
if (user) {
try {
await sendForgotUsernameEmail(user.email, user.username);
} catch (err) {
console.error('Error sending forgot-username email:', err);
}
}
res.json({ message: 'If the email exists, you will receive a message with your username.' });
} catch (error) {
console.error('Error in forgot-username:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Forgot password — generate reset token and send email
app.post('/api/auth/forgot-password', forgotPasswordLimiter, async (req, res) => {
try {
const { email } = req.body || {};
if (!email || !String(email).trim()) {
return res.status(400).json({ error: 'Email is required' });
}
const user = await userDbGet('SELECT id, username, email FROM users WHERE email = ?', [String(email).trim()]);
if (user) {
const token = crypto.randomBytes(32).toString('hex');
const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 hour
await userDbRun(
'INSERT INTO password_reset_tokens (user_id, token, expires_at) VALUES (?, ?, ?)',
[user.id, token, expiresAt.toISOString()]
);
try {
await sendPasswordResetEmail(user.email, user.username, token);
} catch (err) {
console.error('Error sending password reset email:', err);
}
}
res.json({ message: 'If the email exists, you will receive a password reset link.' });
} catch (error) {
console.error('Error in forgot-password:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Reset password — validate token and update password
app.post('/api/auth/reset-password', forgotPasswordLimiter, async (req, res) => {
try {
const { token, password } = req.body || {};
if (!token) return res.status(400).json({ error: 'Token is required' });
if (!password || String(password).length < 8) {
return res.status(400).json({ error: 'Password must be at least 8 characters' });
}
const row = await userDbGet(
'SELECT id, user_id, used, expires_at FROM password_reset_tokens WHERE token = ?',
[token]
);
if (!row) return res.status(400).json({ error: 'Invalid or expired token' });
if (row.used) return res.status(400).json({ error: 'Token already used' });
const expiresAt = new Date(row.expires_at);
if (isNaN(expiresAt.getTime()) || expiresAt < new Date()) return res.status(400).json({ error: 'Token has expired' });
const passwordHash = await bcrypt.hash(String(password), 10);
await userDbRun('UPDATE users SET password_hash = ? WHERE id = ?', [passwordHash, row.user_id]);
await userDbRun('UPDATE password_reset_tokens SET used = 1 WHERE id = ?', [row.id]);
res.json({ message: 'Password updated successfully.' });
} catch (error) {
console.error('Error in reset-password:', error);
res.status(500).json({ error: 'Internal server error' });
}
});
// Check authentication status — reads fresh user record so profile fields stay current // Check authentication status — reads fresh user record so profile fields stay current
app.get('/api/auth/check', async (req, res) => { app.get('/api/auth/check', async (req, res) => {
try { try {
@@ -2648,4 +2773,3 @@ if (process.env.NODE_ENV !== 'test') {
}); });
}); });
} }
+30
View File
@@ -0,0 +1,30 @@
const PLACEHOLDERS = new Set([
'',
'change-me-in-production',
'farma-clic-secret-key-change-in-production',
'replace-me',
'dev-ingest-key-change-me',
])
export function validateProductionEnv(env = process.env) {
if (env.NODE_ENV !== 'production') return
const required = [
['SESSION_SECRET', env.SESSION_SECRET],
['CORS_ORIGIN', env.CORS_ORIGIN],
]
for (const [name, value] of required) {
if (!value || PLACEHOLDERS.has(value)) {
throw new Error(`${name} must be set to a non-placeholder value in production`)
}
}
if (env.CORS_ORIGIN.includes('localhost') || env.CORS_ORIGIN.includes('127.0.0.1')) {
throw new Error('CORS_ORIGIN must not point to localhost in production')
}
if (!env.PG_URL && (!env.PG_PASSWORD || PLACEHOLDERS.has(env.PG_PASSWORD))) {
throw new Error('PG_URL or PG_PASSWORD must be set to a non-placeholder value in production')
}
}
+86
View File
@@ -0,0 +1,86 @@
import nodemailer from 'nodemailer';
const SMTP_HOST = process.env.SMTP_HOST || 'localhost';
const SMTP_PORT = parseInt(process.env.SMTP_PORT || '1025', 10);
const SMTP_USER = process.env.SMTP_USER || '';
const SMTP_PASS = process.env.SMTP_PASS || '';
const SMTP_FROM = process.env.SMTP_FROM || 'noreply@farmafinder.com';
const APP_URL = process.env.APP_URL || 'http://localhost:3000';
let transporter = null;
function getTransporter() {
if (transporter) return transporter;
const auth = SMTP_USER && SMTP_PASS ? { user: SMTP_USER, pass: SMTP_PASS } : undefined;
transporter = nodemailer.createTransport({
host: SMTP_HOST,
port: SMTP_PORT,
secure: SMTP_PORT === 465,
auth,
ignoreTLS: SMTP_PORT !== 465 && !SMTP_USER,
});
return transporter;
}
export async function sendPasswordResetEmail(email, username, token) {
const resetUrl = `${APP_URL}/reset-password?token=${token}`;
const html = `
<!DOCTYPE html>
<html>
<head><meta charset="utf-8"></head>
<body style="font-family: sans-serif; max-width: 480px; margin: 0 auto; padding: 24px;">
<h2>Restablecer contraseña — FarmaFinder</h2>
<p>Hola <strong>${username}</strong>,</p>
<p>Has solicitado restablecer tu contraseña. Haz clic en el siguiente enlace para crear una nueva:</p>
<p style="text-align: center; margin: 32px 0;">
<a href="${resetUrl}"
style="background: #2563eb; color: #fff; padding: 12px 24px; border-radius: 6px; text-decoration: none; display: inline-block;">
Restablecer contraseña
</a>
</p>
<p>Si no has solicitado este cambio, ignora este mensaje.</p>
<p>El enlace caduca en 1 hora.</p>
<hr style="margin-top: 32px; border: none; border-top: 1px solid #e5e7eb;">
<p style="color: #6b7280; font-size: 12px;">FarmaFinder — Encuentra tus medicamentos en farmacias cercanas</p>
</body>
</html>
`;
const text = `Restablecer contraseña — FarmaFinder\n\nHola ${username},\n\nHas solicitado restablecer tu contraseña. Abre este enlace para crear una nueva:\n${resetUrl}\n\nSi no has solicitado este cambio, ignora este mensaje.\nEl enlace caduca en 1 hora.`;
await getTransporter().sendMail({
from: SMTP_FROM,
to: email,
subject: 'Restablece tu contraseña — FarmaFinder',
text,
html,
});
}
export async function sendForgotUsernameEmail(email, username) {
const html = `
<!DOCTYPE html>
<html>
<head><meta charset="utf-8"></head>
<body style="font-family: sans-serif; max-width: 480px; margin: 0 auto; padding: 24px;">
<h2>Tu usuario — FarmaFinder</h2>
<p>Has solicitado recordar tu nombre de usuario.</p>
<p style="font-size: 20px; text-align: center; padding: 16px; background: #f3f4f6; border-radius: 6px; margin: 24px 0;">
<strong>${username}</strong>
</p>
<p>Puedes iniciar sesión con este usuario y tu contraseña.</p>
<p>Si no has solicitado este dato, ignora este mensaje.</p>
<hr style="margin-top: 32px; border: none; border-top: 1px solid #e5e7eb;">
<p style="color: #6b7280; font-size: 12px;">FarmaFinder — Encuentra tus medicamentos en farmacias cercanas</p>
</body>
</html>
`;
const text = `Tu usuario — FarmaFinder\n\nHas solicitado recordar tu nombre de usuario.\n\nTu usuario es: ${username}\n\nSi no has solicitado este dato, ignora este mensaje.`;
await getTransporter().sendMail({
from: SMTP_FROM,
to: email,
subject: 'Tu nombre de usuario — FarmaFinder',
text,
html,
});
}
+109
View File
@@ -0,0 +1,109 @@
/**
* Compute "open now" / "always open" for a pharmacy whose opening_hours is
* stored as JSON in the { mon, tue, ..., sun } shape produced by
* apps/API/opening-hours-osm.js.
*
* 24/7 is internally represented as every day ["00:00", "24:00"]. The
* literal "24:00" is not valid HH:mm ISO; we accept it as 1440 minutes
* (00:00 of the next day) so range math works, and treat a 24h week as
* the dedicated `kind: '24h'` result.
*
* Midnight-crossing ranges (close <= open) are detected by comparing
* the previous day's range when "now" is in the early hours.
*/
const DAYS = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'];
function parse(raw) {
if (raw == null || raw === '') return null;
if (typeof raw === 'object') return raw;
try {
return JSON.parse(raw);
} catch {
return null;
}
}
function toMin(hm) {
if (hm == null) return null;
const parts = String(hm).split(':');
if (parts.length !== 2) return null;
const h = Number(parts[0]);
const m = Number(parts[1]);
if (!Number.isFinite(h) || !Number.isFinite(m)) return null;
if (h === 24 && m === 0) return 1440;
if (h < 0 || h > 24 || m < 0 || m >= 60) return null;
return h * 60 + m;
}
function findNextOpen(h, now) {
for (let off = 1; off <= 7; off++) {
const key = DAYS[(now.getDay() + off) % 7];
const r = h[key];
if (Array.isArray(r) && r.length === 2) {
return { day: key, time: r[0] };
}
}
return null;
}
export function isAlwaysOpen(rawHours) {
const h = parse(rawHours);
if (!h || typeof h !== 'object') return false;
for (const d of DAYS) {
const r = h[d];
if (!Array.isArray(r) || r.length !== 2) return false;
if (r[0] !== '00:00' || r[1] !== '24:00') return false;
}
return true;
}
export function isOpenNow(rawHours, now = new Date()) {
const h = parse(rawHours);
if (!h) return null;
if (isAlwaysOpen(h)) {
return { isOpen: true, kind: '24h' };
}
const dayKey = DAYS[now.getDay()];
const range = h[dayKey];
if (!Array.isArray(range) || range.length !== 2) {
return { isOpen: false, kind: 'closed', nextOpen: findNextOpen(h, now) };
}
const openM = toMin(range[0]);
const closeM = toMin(range[1]);
if (openM == null || closeM == null) return null;
const nowM = now.getHours() * 60 + now.getMinutes();
// First: is "now" still inside yesterday's midnight-crossing range?
// (Today is irrelevant while we're still in the previous day's after-midnight tail.)
const yestKey = DAYS[(now.getDay() + 6) % 7];
const yest = h[yestKey];
if (Array.isArray(yest) && yest.length === 2) {
const yOpen = toMin(yest[0]);
const yClose = toMin(yest[1]);
if (yOpen != null && yClose != null && yClose <= yOpen && nowM < yClose) {
return { isOpen: true, kind: 'open', closesAt: yest[1] };
}
}
// Midnight-crossing: today's range spans past 24:00.
if (closeM <= openM) {
if (nowM >= openM) {
return { isOpen: true, kind: 'open', closesAt: range[1] };
}
return { isOpen: false, kind: 'before-open', opensAt: range[0], nextOpen: null };
}
if (nowM < openM) {
return { isOpen: false, kind: 'before-open', opensAt: range[0], nextOpen: null };
}
if (nowM >= closeM) {
return { isOpen: false, kind: 'after-close', nextOpen: findNextOpen(h, now) };
}
return { isOpen: true, kind: 'open', closesAt: range[1] };
}
-5
View File
@@ -6,8 +6,3 @@ EXPO_PUBLIC_API_URL=http://localhost:3001/api
# For production builds, update this to: # For production builds, update this to:
# EXPO_PUBLIC_API_URL=https://api.yourdomain.com/api # EXPO_PUBLIC_API_URL=https://api.yourdomain.com/api
# Grafana Alloy OTLP/HTTP endpoint for Faro RUM (must be reachable from the
# device — use a LAN IP / public hostname, NOT localhost).
# Open router :4318 → srv84-macos:4318, or proxy /faro via Nginx Proxy Manager.
EXPO_PUBLIC_FARO_URL=http://grafana.hacecalor.net:4318
+1 -7
View File
@@ -24,13 +24,7 @@
"foregroundImage": "./assets/adaptive-icon.png", "foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#007AFF" "backgroundColor": "#007AFF"
}, },
"package": "com.farmafinder.app", "package": "com.farmafinder.app"
"googleServicesFile": "./google-services.json",
"config": {
"googleMaps": {
"apiKey": ""
}
}
}, },
"plugins": [ "plugins": [
"expo-router", "expo-router",
+8 -1
View File
@@ -1,6 +1,6 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { View, StyleSheet, Text } from 'react-native'; import { View, StyleSheet, Text } from 'react-native';
import MapView, { Marker } from 'react-native-maps'; import MapView, { Marker, UrlTile } from 'react-native-maps';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { getPharmacies } from '../../services/pharmacies'; import { getPharmacies } from '../../services/pharmacies';
import { LoadingSpinner } from '../../components/LoadingSpinner'; import { LoadingSpinner } from '../../components/LoadingSpinner';
@@ -54,11 +54,18 @@ export default function MapScreen() {
<View style={styles.container}> <View style={styles.container}>
<MapView <MapView
style={styles.map} style={styles.map}
mapType="none"
region={region} region={region}
onRegionChangeComplete={setRegion} onRegionChangeComplete={setRegion}
showsUserLocation={true} showsUserLocation={true}
showsMyLocationButton={true} showsMyLocationButton={true}
> >
<UrlTile
urlTemplate="https://tile.openstreetmap.org/{z}/{x}/{y}.png"
maximumZ={19}
flipY={false}
tileSize={256}
/>
{pharmacies.map((pharmacy) => ( {pharmacies.map((pharmacy) => (
<Marker <Marker
key={pharmacy.id} key={pharmacy.id}
+25 -24
View File
@@ -174,32 +174,33 @@ export default function SearchScreen() {
data={results} data={results}
keyExtractor={(item) => item.nregistro} keyExtractor={(item) => item.nregistro}
renderItem={({ item }) => <MedicineCard medicine={item} />} renderItem={({ item }) => <MedicineCard medicine={item} />}
ListHeaderComponent={ ListEmptyComponent={
<> products.length === 0 ? null : undefined
{products.length > 0 && ( }
<View style={styles.section}> ListFooterComponent={
<Text style={[styles.sectionTitle, { color: colors.text }]}>{t('search.parapharmacy')}</Text> products.length > 0 ? (
{products.map((product) => ( <View style={styles.section}>
<TouchableOpacity <Text style={[styles.sectionTitle, { color: colors.text }]}>{t('search.parapharmacy')}</Text>
key={`${product.source}-${product.id || product._id}`} {products.map((product) => (
style={[styles.productCard, { backgroundColor: colors.card, borderColor: colors.border }]} <TouchableOpacity
onPress={() => router.push(`/product/${product.source}/${product.id || product._id}`)} key={`${product.source}-${product.id || product._id}`}
activeOpacity={0.7} style={[styles.productCard, { backgroundColor: colors.card, borderColor: colors.border }]}
> onPress={() => router.push(`/product/${product.source}/${product.id || product._id}`)}
<View style={styles.productInfo}> activeOpacity={0.7}
<View style={styles.badges}> >
<View style={[styles.sourceBadge, { backgroundColor: '#16a34a' }]}> <View style={styles.productInfo}>
<Text style={styles.badgeText}>{t('search.parapharmacy')}</Text> <View style={styles.badges}>
</View> <View style={[styles.sourceBadge, { backgroundColor: '#16a34a' }]}>
<Text style={styles.badgeText}>{t('search.parapharmacy')}</Text>
</View> </View>
<Text style={[styles.productName, { color: colors.text }]} numberOfLines={1}>{product.name}</Text>
<Text style={[styles.productBrand, { color: colors.textSecondary }]} numberOfLines={1}>{product.brand} • {product.price}€</Text>
</View> </View>
</TouchableOpacity> <Text style={[styles.productName, { color: colors.text }]} numberOfLines={1}>{product.name}</Text>
))} <Text style={[styles.productBrand, { color: colors.textSecondary }]} numberOfLines={1}>{product.brand} • {product.price}€</Text>
</View> </View>
)} </TouchableOpacity>
</> ))}
</View>
) : null
} }
contentContainerStyle={[styles.list, isTablet && styles.listTablet]} contentContainerStyle={[styles.list, isTablet && styles.listTablet]}
showsVerticalScrollIndicator={false} showsVerticalScrollIndicator={false}
-4
View File
@@ -9,10 +9,6 @@ import { useAuthStore } from '../store/authStore';
import { registerForPushNotifications, addNotificationListener, addNotificationResponseListener } from '../services/notifications'; import { registerForPushNotifications, addNotificationListener, addNotificationResponseListener } from '../services/notifications';
import { ThemeProvider, useThemeContext } from '../components/ThemeProvider'; import { ThemeProvider, useThemeContext } from '../components/ThemeProvider';
import { LanguageProvider, useTranslation } from '../src/i18n'; import { LanguageProvider, useTranslation } from '../src/i18n';
import { initFaro } from '../services/faro';
// Boot Faro RUM once, as early as possible.
initFaro();
const queryClient = new QueryClient(); const queryClient = new QueryClient();
+35 -11
View File
@@ -3,6 +3,7 @@ import { View, Text, ScrollView, StyleSheet, TouchableOpacity, Linking } from 'r
import { useLocalSearchParams, useRouter } from 'expo-router'; import { useLocalSearchParams, useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import * as Location from 'expo-location'; import * as Location from 'expo-location';
import MapView, { Marker, UrlTile } from 'react-native-maps';
import { getMedicine, getMedicinePharmacies } from '../../services/medicines'; import { getMedicine, getMedicinePharmacies } from '../../services/medicines';
import { subscribeToMedicine, unsubscribeFromMedicine } from '../../services/notifications'; import { subscribeToMedicine, unsubscribeFromMedicine } from '../../services/notifications';
import { useAuth } from '../../hooks/useAuth'; import { useAuth } from '../../hooks/useAuth';
@@ -205,11 +206,38 @@ export default function MedicineDetailScreen() {
</View> </View>
{locatedPharmacies.length > 0 && ( {locatedPharmacies.length > 0 && (
<View style={[styles.mapContainer, { backgroundColor: colors.surfaceVariant }]}> <View style={styles.mapContainer}>
<Ionicons name="map-outline" size={48} color={colors.textSecondary} /> <MapView
<Text style={[styles.mapPlaceholder, { color: colors.textSecondary }]}> style={styles.map}
{t('medicine.mapSoon')} mapType="none"
</Text> initialRegion={{
latitude: mapCenter.latitude,
longitude: mapCenter.longitude,
latitudeDelta: 0.05,
longitudeDelta: 0.05,
}}
>
<UrlTile
urlTemplate="https://tile.openstreetmap.org/{z}/{x}/{y}.png"
maximumZ={19}
flipY={false}
tileSize={256}
/>
{locatedPharmacies.map((pharm) => {
const lat = getPharmacyLat(pharm);
const lon = getPharmacyLon(pharm);
if (lat == null || lon == null) return null;
return (
<Marker
key={pharm.id}
coordinate={{ latitude: lat, longitude: lon }}
title={pharm.pharmacy?.name || pharm.name}
description={pharm.pharmacy?.address || pharm.address}
onCalloutPress={() => router.push(`/pharmacy/${pharm.pharmacy_id || pharm.id}`)}
/>
);
})}
</MapView>
</View> </View>
)} )}
@@ -362,13 +390,9 @@ const styles = StyleSheet.create({
height: 200, height: 200,
borderRadius: borderRadius.lg, borderRadius: borderRadius.lg,
overflow: 'hidden', overflow: 'hidden',
alignItems: 'center',
justifyContent: 'center',
gap: spacing.sm,
}, },
mapPlaceholder: { map: {
fontSize: 14, flex: 1,
fontWeight: '500',
}, },
pharmaciesSection: { pharmaciesSection: {
marginTop: spacing.sm, marginTop: spacing.sm,
+8 -1
View File
@@ -2,7 +2,7 @@ import React, { useEffect, useState } from 'react';
import { View, Text, ScrollView, StyleSheet, TouchableOpacity, Linking } from 'react-native'; import { View, Text, ScrollView, StyleSheet, TouchableOpacity, Linking } from 'react-native';
import { useLocalSearchParams, useRouter } from 'expo-router'; import { useLocalSearchParams, useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import MapView, { Marker } from 'react-native-maps'; import MapView, { Marker, UrlTile } from 'react-native-maps';
import { getPharmacy, getPharmacyMedicines } from '../../services/pharmacies'; import { getPharmacy, getPharmacyMedicines } from '../../services/pharmacies';
import { subscribeToMedicine, unsubscribeFromMedicine } from '../../services/notifications'; import { subscribeToMedicine, unsubscribeFromMedicine } from '../../services/notifications';
import { useAuth } from '../../hooks/useAuth'; import { useAuth } from '../../hooks/useAuth';
@@ -125,6 +125,7 @@ export default function PharmacyDetailScreen() {
<View style={styles.mapContainer}> <View style={styles.mapContainer}>
<MapView <MapView
style={styles.map} style={styles.map}
mapType="none"
initialRegion={{ initialRegion={{
latitude: pharmacy.latitude, latitude: pharmacy.latitude,
longitude: pharmacy.longitude, longitude: pharmacy.longitude,
@@ -133,6 +134,12 @@ export default function PharmacyDetailScreen() {
}} }}
scrollEnabled={false} scrollEnabled={false}
> >
<UrlTile
urlTemplate="https://tile.openstreetmap.org/{z}/{x}/{y}.png"
maximumZ={19}
flipY={false}
tileSize={256}
/>
<Marker <Marker
coordinate={{ coordinate={{
latitude: pharmacy.latitude, latitude: pharmacy.latitude,
-1
View File
@@ -4,7 +4,6 @@
"main": "expo-router/entry", "main": "expo-router/entry",
"dependencies": { "dependencies": {
"@expo/vector-icons": "^15.0.2", "@expo/vector-icons": "^15.0.2",
"@grafana/faro-react-native": "^1.3.0",
"@react-native-async-storage/async-storage": "2.2.0", "@react-native-async-storage/async-storage": "2.2.0",
"@tanstack/react-query": "^5.101.2", "@tanstack/react-query": "^5.101.2",
"axios": "^1.18.1", "axios": "^1.18.1",
-36
View File
@@ -1,36 +0,0 @@
import { initializeFaro } from '@grafana/faro-react-native';
let initialized = false;
/**
* Initializes Grafana Faro RUM for the React Native app.
*
* Telemetry (crashes, JS errors, console, app-start, memory/ANR vitals,
* session + screen tracking) is sent OTLP/HTTP to the Alloy collector.
*
* NOTE: `@grafana/faro-react-native` ships a native module, so it requires a
* custom dev build / EAS build (not Expo Go). Initialization is wrapped in a
* try/catch so a collector outage never crashes the app.
*/
export function initFaro(): void {
if (initialized) return;
initialized = true;
try {
const url =
process.env.EXPO_PUBLIC_FARO_URL || 'http://grafana.hacecalor.net:4318';
initializeFaro({
app: {
name: 'farmafinder-mobile',
version: '1.0.0',
environment: __DEV__ ? 'development' : 'production',
},
url,
sessionTracking: { enabled: true },
// Crash/JS error/console capture are on by default.
});
} catch (err) {
console.warn('[faro] initialization failed:', err);
}
}
+6
View File
@@ -1,8 +1,14 @@
server { server {
listen 80; listen 80;
server_tokens off;
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
location /api/ { location /api/ {
proxy_pass http://backend:3001; proxy_pass http://backend:3001;
proxy_set_header Host $host; proxy_set_header Host $host;
+2 -2
View File
@@ -28,10 +28,10 @@
"devDependencies": { "devDependencies": {
"@testing-library/jest-dom": "^6.4.0", "@testing-library/jest-dom": "^6.4.0",
"@testing-library/react": "^14.2.0", "@testing-library/react": "^14.2.0",
"@vitejs/plugin-react": "^4.2.1", "@vitejs/plugin-react": "^4.7.0",
"jsdom": "^24.0.0", "jsdom": "^24.0.0",
"vite": "^5.0.8", "vite": "^5.0.8",
"vite-plugin-pwa": "^1.3.0", "vite-plugin-pwa": "^1.3.0",
"vitest": "^1.6.0" "vitest": "^1.6.1"
} }
} }
+44
View File
@@ -8,6 +8,8 @@ import AlertsView from './views/AlertsView';
import ProfileView from './views/ProfileView'; import ProfileView from './views/ProfileView';
import AdminView from './views/AdminView'; import AdminView from './views/AdminView';
import LoginModal from './components/LoginModal'; import LoginModal from './components/LoginModal';
import ForgotPasswordModal from './components/ForgotPasswordModal';
import ResetPasswordView from './views/ResetPasswordView';
import SavedNotifications from './components/SavedNotifications'; import SavedNotifications from './components/SavedNotifications';
import BottomNav from './components/BottomNav'; import BottomNav from './components/BottomNav';
import { getFaro } from './utils/faro'; import { getFaro } from './utils/faro';
@@ -17,6 +19,9 @@ function App() {
const [currentUser, setCurrentUser] = useState(null); const [currentUser, setCurrentUser] = useState(null);
const [authChecked, setAuthChecked] = useState(false); const [authChecked, setAuthChecked] = useState(false);
const [showLogin, setShowLogin] = useState(false); const [showLogin, setShowLogin] = useState(false);
const [showForgot, setShowForgot] = useState(false);
const [forgotMode, setForgotMode] = useState('password');
const [resetToken, setResetToken] = useState(null);
const [showSaved, setShowSaved] = useState(false); const [showSaved, setShowSaved] = useState(false);
const [badgeCount, setBadgeCount] = useState(0); const [badgeCount, setBadgeCount] = useState(0);
const [prescriptionSearch, setPrescriptionSearch] = useState(''); const [prescriptionSearch, setPrescriptionSearch] = useState('');
@@ -67,6 +72,13 @@ function App() {
}); });
}; };
const params = new URLSearchParams(window.location.search);
const token = params.get('token');
if (token) {
setResetToken(token);
setScreen('reset-password');
}
fetch('/api/auth/check') fetch('/api/auth/check')
.then(r => r.json()) .then(r => r.json())
.then(data => { if (data.authenticated) setCurrentUser(data.user); }) .then(data => { if (data.authenticated) setCurrentUser(data.user); })
@@ -126,6 +138,18 @@ function App() {
setCurrentUser(prev => ({ ...prev, ...updated })); setCurrentUser(prev => ({ ...prev, ...updated }));
} }
function handleForgotPassword(mode) {
setForgotMode(mode);
setShowLogin(false);
setShowForgot(true);
}
function handleResetComplete() {
setResetToken(null);
setShowLogin(true);
window.history.replaceState({}, '', '/');
}
function handleAdminClick() { function handleAdminClick() {
setScreen('admin'); setScreen('admin');
} }
@@ -211,6 +235,14 @@ function App() {
/> />
); );
break; break;
case 'reset-password':
activeView = (
<ResetPasswordView
token={resetToken}
onReset={handleResetComplete}
/>
);
break;
case 'admin': case 'admin':
activeView = <AdminView />; activeView = <AdminView />;
break; break;
@@ -243,6 +275,18 @@ function App() {
<LoginModal <LoginModal
onLogin={handleLogin} onLogin={handleLogin}
onClose={() => setShowLogin(false)} onClose={() => setShowLogin(false)}
onForgotPassword={handleForgotPassword}
/>
)}
{showForgot && (
<ForgotPasswordModal
initialMode={forgotMode}
onClose={() => setShowForgot(false)}
onBackToLogin={() => {
setShowForgot(false);
setShowLogin(true);
}}
/> />
)} )}
@@ -0,0 +1,77 @@
.forgot-box {
max-width: 380px;
}
.forgot-back {
background: none;
border: none;
color: var(--primary);
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
padding: 0;
margin-bottom: 1rem;
display: inline-block;
}
.forgot-back:hover {
opacity: 0.8;
}
.forgot-options {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-top: 0.5rem;
}
.forgot-option {
display: flex;
align-items: center;
gap: 0.85rem;
background: var(--surface-muted);
border: 1px solid var(--border);
border-radius: calc(var(--radius) - 4px);
padding: 1rem;
cursor: pointer;
text-align: left;
transition: border-color 0.15s, background 0.15s;
font-size: 0.9rem;
color: var(--text-main);
}
.forgot-option:hover {
border-color: var(--primary);
background: var(--surface);
}
.forgot-option-icon {
font-size: 1.5rem;
flex-shrink: 0;
}
.forgot-option-text {
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.forgot-option-desc {
font-size: 0.8rem;
color: var(--text-muted);
font-weight: 400;
}
.forgot-sent-icon {
text-align: center;
font-size: 2.5rem;
margin-bottom: 0.75rem;
}
.forgot-sent-text {
font-size: 0.9rem;
color: var(--text-muted);
line-height: 1.5;
text-align: center;
margin: 0 0 0.5rem;
}
@@ -0,0 +1,159 @@
import React, { useState, useEffect, useRef } from 'react';
import { useTranslation } from '../i18n';
import './LoginModal.css';
import './ForgotPasswordModal.css';
function ForgotPasswordModal({ onClose, onBackToLogin, initialMode }) {
const { t } = useTranslation();
const [step, setStep] = useState(initialMode ? 'email' : 'choose'); // choose | email | sent
const [mode, setMode] = useState(initialMode || ''); // username | password
const [email, setEmail] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const emailRef = useRef(null);
useEffect(() => {
emailRef.current?.focus();
function handleKey(e) { if (e.key === 'Escape') onClose(); }
document.addEventListener('keydown', handleKey);
return () => document.removeEventListener('keydown', handleKey);
}, [onClose]);
function handleChoose(m) {
setMode(m);
setStep('email');
setError('');
}
async function handleSubmit(e) {
e.preventDefault();
const em = email.trim();
if (!em) return;
setLoading(true);
setError('');
try {
const endpoint = mode === 'password'
? '/api/auth/forgot-password'
: '/api/auth/forgot-username';
const res = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: em }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setError(data.error || t('forgot.error'));
return;
}
setStep('sent');
} catch {
setError(t('login.networkError'));
} finally {
setLoading(false);
}
}
function handleBack() {
if (step === 'email') setStep('choose');
else onBackToLogin();
}
return (
<div className="modal-overlay" onClick={onClose}>
<div
className="modal-box forgot-box"
onClick={e => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby="forgot-title"
>
{step === 'choose' && (
<>
<button type="button" className="forgot-back" onClick={onBackToLogin} aria-label={t('forgot.back')}>
← {t('forgot.backToLogin')}
</button>
<h2 id="forgot-title">{t('forgot.title')}</h2>
<p className="modal-sub">{t('forgot.subtitle')}</p>
<div className="forgot-options">
<button
type="button"
className="forgot-option"
onClick={() => handleChoose('password')}
>
<span className="forgot-option-icon">🔑</span>
<span className="forgot-option-text">
<strong>{t('forgot.password')}</strong>
<span className="forgot-option-desc">{t('forgot.passwordDesc')}</span>
</span>
</button>
<button
type="button"
className="forgot-option"
onClick={() => handleChoose('username')}
>
<span className="forgot-option-icon">👤</span>
<span className="forgot-option-text">
<strong>{t('forgot.username')}</strong>
<span className="forgot-option-desc">{t('forgot.usernameDesc')}</span>
</span>
</button>
</div>
</>
)}
{step === 'email' && (
<>
<button type="button" className="forgot-back" onClick={handleBack} aria-label={t('forgot.back')}>
← {t('forgot.back')}
</button>
<h2 id="forgot-title">
{mode === 'password' ? t('forgot.resetTitle') : t('forgot.usernameTitle')}
</h2>
<p className="modal-sub">
{mode === 'password' ? t('forgot.resetDesc') : t('forgot.usernameDesc2')}
</p>
<form onSubmit={handleSubmit} noValidate>
<div className="modal-field">
<label htmlFor="forgot-email">{t('forgot.email')}</label>
<input
id="forgot-email"
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
autoComplete="email"
ref={emailRef}
disabled={loading}
placeholder={t('forgot.emailPlaceholder')}
/>
</div>
{error && <p className="modal-error">{error}</p>}
<div className="modal-actions">
<button type="button" className="modal-cancel" onClick={handleBack} disabled={loading}>
{t('forgot.cancel')}
</button>
<button type="submit" className="modal-submit" disabled={loading || !email.trim()}>
{loading ? t('forgot.sending') : t('forgot.send')}
</button>
</div>
</form>
</>
)}
{step === 'sent' && (
<>
<div className="forgot-sent-icon">✅</div>
<h2 id="forgot-title">{t('forgot.sentTitle')}</h2>
<p className="forgot-sent-text">{t('forgot.sentText')}</p>
<div className="modal-actions" style={{ justifyContent: 'center' }}>
<button type="button" className="modal-submit" onClick={onClose}>
{t('forgot.close')}
</button>
</div>
</>
)}
</div>
</div>
);
}
export default ForgotPasswordModal;
@@ -171,3 +171,27 @@
opacity: 0.5; opacity: 0.5;
cursor: not-allowed; cursor: not-allowed;
} }
.forgot-links {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.35rem;
margin-top: 1rem;
}
.forgot-link {
background: none;
border: none;
color: var(--text-muted);
font-size: 0.8rem;
cursor: pointer;
padding: 0.2rem 0;
text-decoration: underline;
text-underline-offset: 2px;
transition: color 0.15s;
}
.forgot-link:hover {
color: #0a0b0a;
}
+19 -1
View File
@@ -2,7 +2,7 @@ import React, { useState, useEffect, useRef } from 'react';
import { useTranslation } from '../i18n'; import { useTranslation } from '../i18n';
import './LoginModal.css'; import './LoginModal.css';
function LoginModal({ onLogin, onClose, initialMode = 'login' }) { function LoginModal({ onLogin, onClose, initialMode = 'login', onForgotPassword }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [mode, setMode] = useState(initialMode === 'register' ? 'register' : 'login'); const [mode, setMode] = useState(initialMode === 'register' ? 'register' : 'login');
const [username, setUsername] = useState(''); const [username, setUsername] = useState('');
@@ -148,6 +148,24 @@ function LoginModal({ onLogin, onClose, initialMode = 'login' }) {
</button> </button>
</div> </div>
</form> </form>
{!isRegister && (
<div className="forgot-links">
<button
type="button"
className="forgot-link"
onClick={() => onForgotPassword('password')}
>
{t('forgot.forgotPassword')}
</button>
<button
type="button"
className="forgot-link"
onClick={() => onForgotPassword('username')}
>
{t('forgot.forgotUsername')}
</button>
</div>
)}
</div> </div>
</div> </div>
); );
@@ -110,6 +110,11 @@
color: var(--on-surface-variant); color: var(--on-surface-variant);
} }
.pharmacy-hours--unknown {
color: var(--on-surface-variant);
opacity: 0.6;
}
.pharmacy-pricing { .pharmacy-pricing {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
@@ -107,7 +107,10 @@ function PharmacyCard({ pharmacy, distanceKm, medicine, currentUser, onLoginRequ
return ( return (
<div className="pharmacy-card"> <div className="pharmacy-card">
<div className="pharmacy-header"> <div className="pharmacy-header">
<h4>🏥 {pharmacy.name}</h4> <h4>
🏥 {pharmacy.name}
{pharmacy.is_24h && <span className="pharmacy-badge pharmacy-badge--24h" aria-label={t('pharmacy.badge24h')}>24h</span>}
</h4>
<div className="pharmacy-header-actions"> <div className="pharmacy-header-actions">
{distanceKm != null && ( {distanceKm != null && (
<span className="pharmacy-distance">{formatDistance(distanceKm)}</span> <span className="pharmacy-distance">{formatDistance(distanceKm)}</span>
@@ -142,7 +145,7 @@ function PharmacyCard({ pharmacy, distanceKm, medicine, currentUser, onLoginRequ
<div className="pharmacy-details"> <div className="pharmacy-details">
{openStatus && ( {openStatus && (
<p className={`pharmacy-hours pharmacy-hours--${openStatus.status}`}> <p className={`pharmacy-hours pharmacy-hours--${openStatus.status}`}>
<span className="pharmacy-hours-dot" /> {openStatus.label} <span className="pharmacy-hours-dot" /> {openStatus.labelKey && openStatus.labelParams ? t(openStatus.labelKey, openStatus.labelParams) : openStatus.label}
</p> </p>
)} )}
<p className="pharmacy-address">📍 {pharmacy.address}</p> <p className="pharmacy-address">📍 {pharmacy.address}</p>
+1 -1
View File
@@ -31,7 +31,7 @@ function PharmacyMap({ pharmacies }) {
{located.map(pharmacy => ( {located.map(pharmacy => (
<Marker key={pharmacy.id} position={[pharmacy.latitude, pharmacy.longitude]}> <Marker key={pharmacy.id} position={[pharmacy.latitude, pharmacy.longitude]}>
<Popup> <Popup>
<strong>{pharmacy.name}</strong><br /> <strong>{pharmacy.name} {pharmacy.is_24h && <span className="map-badge-24h">24h</span>}</strong><br />
{pharmacy.address} {pharmacy.address}
{pharmacy.phone && <><br />{pharmacy.phone}</>} {pharmacy.phone && <><br />{pharmacy.phone}</>}
<br /> <br />
@@ -1,49 +1,8 @@
import React, { useState, useEffect, useMemo } from 'react'; import React, { useState, useEffect, useMemo, useCallback } from 'react';
import './AdminComponents.css'; import './AdminComponents.css';
import { DAY_KEYS, DAY_LABEL } from '../../utils/hours'; import { DAY_KEYS, DAY_LABEL, emptyHoursDraft, hoursToDraft, draftToHours, makeAlwaysOpenDraft, isAlwaysOpen } from '../../utils/hours';
import { useTranslation } from '../../i18n'; import { useTranslation } from '../../i18n';
function emptyHoursDraft() {
const draft = {};
for (const day of DAY_KEYS) {
draft[day] = { open: '09:00', close: '21:00', closed: true };
}
return draft;
}
function hoursToDraft(raw) {
let parsed = null;
if (raw) {
try { parsed = typeof raw === 'string' ? JSON.parse(raw) : raw; }
catch { parsed = null; }
}
const draft = {};
for (const day of DAY_KEYS) {
const v = parsed && parsed[day];
if (Array.isArray(v) && v.length === 2) {
draft[day] = { open: v[0], close: v[1], closed: false };
} else {
draft[day] = { open: '09:00', close: '21:00', closed: true };
}
}
return draft;
}
function draftToHours(draft) {
const out = {};
let hasAny = false;
for (const day of DAY_KEYS) {
const d = draft[day];
if (d && !d.closed && d.open && d.close) {
out[day] = [d.open, d.close];
hasAny = true;
} else {
out[day] = null;
}
}
return hasAny ? out : null;
}
/** Distance in metres between two WGS84 points */ /** Distance in metres between two WGS84 points */
function haversineMeters(lat1, lon1, lat2, lon2) { function haversineMeters(lat1, lon1, lat2, lon2) {
const R = 6371000; const R = 6371000;
@@ -629,6 +588,22 @@ function PharmacyManagement() {
<fieldset className="hours-editor"> <fieldset className="hours-editor">
<legend>{t('admin.pharmacy.openingHours')}</legend> <legend>{t('admin.pharmacy.openingHours')}</legend>
<p className="hours-editor-hint">{t('admin.pharmacy.dayClosed')}</p> <p className="hours-editor-hint">{t('admin.pharmacy.dayClosed')}</p>
<label className="hours-24h-toggle">
<input
type="checkbox"
checked={isAlwaysOpen(draftToHours(hoursDraft))}
onChange={(e) => {
if (e.target.checked) {
setHoursDraft(makeAlwaysOpenDraft());
} else {
if (window.confirm(t('admin.pharmacy.confirmDisable24h'))) {
setHoursDraft(emptyHoursDraft());
}
}
}}
/>
{t('admin.pharmacy.alwaysOpen')}
</label>
{DAY_KEYS.map((day) => { {DAY_KEYS.map((day) => {
const d = hoursDraft[day]; const d = hoursDraft[day];
return ( return (
+52
View File
@@ -66,6 +66,17 @@ const ca = {
'pharmacy.notifyWhenArrives': 'Notificar-me quan arribi a aquesta farmàcia', 'pharmacy.notifyWhenArrives': 'Notificar-me quan arribi a aquesta farmàcia',
'pharmacy.notificationsActivatedPharmacy': 'Notificacions activades per a aquesta farmàcia — clic per desactivar', 'pharmacy.notificationsActivatedPharmacy': 'Notificacions activades per a aquesta farmàcia — clic per desactivar',
'pharmacy.notificationsRequired': 'Les notificacions requereixen iOS 16.4+ i aquest lloc instal·lat com a app (Compartir → Afegir a Pantalla d\'Inici).', 'pharmacy.notificationsRequired': 'Les notificacions requereixen iOS 16.4+ i aquest lloc instal·lat com a app (Compartir → Afegir a Pantalla d\'Inici).',
'pharmacy.openNow': 'Obert · Tanca a les {{time}}',
'pharmacy.closedAllDay': 'Tancat',
'pharmacy.opensAt': 'Tancat · Obre a les {{time}}',
'pharmacy.opensTomorrow': 'Tancat · Obre demà a les {{time}}',
'pharmacy.opensDay': 'Tancat · Obre el {{day}} a les {{time}}',
'pharmacy.alwaysOpen': 'Obert 24h',
'pharmacy.filterOpenNow': 'Mostrar només obertes ara',
'pharmacy.filterOpenNowActive': 'Només obertes ara',
'pharmacy.badge24h': '24h',
'pharmacy.noHours': 'Sense horari disponible',
'pharmacy.filterNoResults': 'Cap farmàcia oberta ara. Desactiva el filtre per veure-les totes.',
// ProductResults // ProductResults
'product.sinReceta': 'Sense Recepta', 'product.sinReceta': 'Sense Recepta',
@@ -240,6 +251,45 @@ const ca = {
'profile.languageCatalan': 'Català', 'profile.languageCatalan': 'Català',
'profile.languageSpanish': 'Castellà', 'profile.languageSpanish': 'Castellà',
// ForgotPasswordModal
'forgot.title': 'Problemes per accedir?',
'forgot.subtitle': 'Tria què necessites recuperar',
'forgot.password': 'He oblidat la meva contrasenya',
'forgot.passwordDesc': 'T\'enviarem un enllaç per crear-ne una de nova',
'forgot.username': 'He oblidat el meu usuari',
'forgot.usernameDesc': 'T\'enviarem el teu nom d\'usuari',
'forgot.resetTitle': 'Restablir contrasenya',
'forgot.resetDesc': 'Introdueix el teu correu electrònic i t\'enviarem un enllaç per crear una nova contrasenya.',
'forgot.usernameTitle': 'Recuperar usuari',
'forgot.usernameDesc2': 'Introdueix el teu correu electrònic i t\'enviarem el teu nom d\'usuari.',
'forgot.email': 'Correu electrònic',
'forgot.emailPlaceholder': 'el_teu@email.com',
'forgot.send': 'Enviar',
'forgot.sending': 'Enviant...',
'forgot.cancel': 'Cancel·lar',
'forgot.back': 'Tornar',
'forgot.backToLogin': 'Tornar a iniciar sessió',
'forgot.sentTitle': 'Correu enviat',
'forgot.sentText': 'Si l\'adreça de correu existeix a la nostra base de dades, rebràs un missatge en uns minuts. Revisa la teva safata d\'entrada.',
'forgot.close': 'Tancar',
'forgot.error': 'Error en processar la sol·licitud',
'forgot.forgotPassword': 'Has oblidat la contrasenya?',
'forgot.forgotUsername': 'Has oblidat l\'usuari?',
// ResetPasswordView
'reset.title': 'Crear nova contrasenya',
'reset.subtitle': 'Introdueix la teva nova contrasenya',
'reset.newPassword': 'Nova contrasenya',
'reset.confirmPassword': 'Confirmar contrasenya',
'reset.saveBtn': 'Canviar contrasenya',
'reset.saving': 'Desant...',
'reset.passwordsDontMatch': 'Les contrasenyes no coincideixen',
'reset.successTitle': 'Contrasenya actualitzada',
'reset.successText': 'La teva contrasenya s\'ha actualitzat correctament. Ja pots iniciar sessió amb la teva nova contrasenya.',
'reset.goToLogin': 'Anar a iniciar sessió',
'reset.error': 'Error en restablir la contrasenya',
'reset.invalidToken': 'Enllaç invàlid o caducat. Sol·licita un nou restabliment de contrasenya.',
// SavedNotifications // SavedNotifications
'savedNotifications.title': '🔔 Notificacions Desades', 'savedNotifications.title': '🔔 Notificacions Desades',
'savedNotifications.close': 'Tancar', 'savedNotifications.close': 'Tancar',
@@ -338,6 +388,8 @@ const ca = {
'admin.pharmacy.apiNotFound': 'L\'app no ha pogut connectar amb l\'API (404). Useu http://localhost:3000 amb frontend i backend actius.', 'admin.pharmacy.apiNotFound': 'L\'app no ha pogut connectar amb l\'API (404). Useu http://localhost:3000 amb frontend i backend actius.',
'admin.pharmacy.geocodificationNotFound': 'Servei de geocodificació no trobat. Actualitzeu el backend i reinicieu-lo.', 'admin.pharmacy.geocodificationNotFound': 'Servei de geocodificació no trobat. Actualitzeu el backend i reinicieu-lo.',
'admin.pharmacy.searchFailed': 'Cerca fallida (HTTP', 'admin.pharmacy.searchFailed': 'Cerca fallida (HTTP',
'admin.pharmacy.alwaysOpen': '24 hores (oberta tot el dia)',
'admin.pharmacy.confirmDisable24h': 'Desactivar 24h? Es descartaran els horaris actuals.',
'admin.pharmacy.dayClosed': 'Marqueu un dia com a Tancat si la farmàcia no obre aquest dia.', 'admin.pharmacy.dayClosed': 'Marqueu un dia com a Tancat si la farmàcia no obre aquest dia.',
'admin.pharmacy.saveError': 'Error en desar farmàcia', 'admin.pharmacy.saveError': 'Error en desar farmàcia',
'admin.pharmacy.radius': 'Radi (m)', 'admin.pharmacy.radius': 'Radi (m)',
+52
View File
@@ -66,6 +66,17 @@ const es = {
'pharmacy.notifyWhenArrives': 'Notificarme cuando llegue a esta farmacia', 'pharmacy.notifyWhenArrives': 'Notificarme cuando llegue a esta farmacia',
'pharmacy.notificationsActivatedPharmacy': 'Notificaciones activadas para esta farmacia — clic para desactivar', 'pharmacy.notificationsActivatedPharmacy': 'Notificaciones activadas para esta farmacia — clic para desactivar',
'pharmacy.notificationsRequired': 'Las notificaciones requieren iOS 16.4+ y este sitio instalado como app (Compartir → Añadir a Pantalla de Inicio).', 'pharmacy.notificationsRequired': 'Las notificaciones requieren iOS 16.4+ y este sitio instalado como app (Compartir → Añadir a Pantalla de Inicio).',
'pharmacy.openNow': 'Abierto · Cierra a las {{time}}',
'pharmacy.closedAllDay': 'Cerrado',
'pharmacy.opensAt': 'Cerrado · Abre a las {{time}}',
'pharmacy.opensTomorrow': 'Cerrado · Abre mañana a las {{time}}',
'pharmacy.opensDay': 'Cerrado · Abre el {{day}} a las {{time}}',
'pharmacy.alwaysOpen': 'Abierto 24h',
'pharmacy.filterOpenNow': 'Mostrar solo abiertas ahora',
'pharmacy.filterOpenNowActive': 'Solo abiertas ahora',
'pharmacy.badge24h': '24h',
'pharmacy.noHours': 'Sin horario disponible',
'pharmacy.filterNoResults': 'Ninguna farmacia abierta ahora. Desactiva el filtro para ver todas.',
// ProductResults // ProductResults
'product.sinReceta': 'Sin Receta', 'product.sinReceta': 'Sin Receta',
@@ -240,6 +251,45 @@ const es = {
'profile.languageCatalan': 'Català', 'profile.languageCatalan': 'Català',
'profile.languageSpanish': 'Castellano', 'profile.languageSpanish': 'Castellano',
// ForgotPasswordModal
'forgot.title': '¿Problemas para acceder?',
'forgot.subtitle': 'Elige qué necesitas recuperar',
'forgot.password': 'He olvidado mi contraseña',
'forgot.passwordDesc': 'Te enviaremos un enlace para crear una nueva',
'forgot.username': 'He olvidado mi usuario',
'forgot.usernameDesc': 'Te enviaremos tu nombre de usuario',
'forgot.resetTitle': 'Restablecer contraseña',
'forgot.resetDesc': 'Introduce tu correo electrónico y te enviaremos un enlace para crear una nueva contraseña.',
'forgot.usernameTitle': 'Recuperar usuario',
'forgot.usernameDesc2': 'Introduce tu correo electrónico y te enviaremos tu nombre de usuario.',
'forgot.email': 'Correo electrónico',
'forgot.emailPlaceholder': 'tu@email.com',
'forgot.send': 'Enviar',
'forgot.sending': 'Enviando...',
'forgot.cancel': 'Cancelar',
'forgot.back': 'Volver',
'forgot.backToLogin': 'Volver a iniciar sesión',
'forgot.sentTitle': 'Correo enviado',
'forgot.sentText': 'Si la dirección de correo existe en nuestra base de datos, recibirás un mensaje en unos minutos. Revisa tu bandeja de entrada.',
'forgot.close': 'Cerrar',
'forgot.error': 'Error al procesar la solicitud',
'forgot.forgotPassword': '¿Olvidaste tu contraseña?',
'forgot.forgotUsername': '¿Olvidaste tu usuario?',
// ResetPasswordView
'reset.title': 'Crear nueva contraseña',
'reset.subtitle': 'Introduce tu nueva contraseña',
'reset.newPassword': 'Nueva contraseña',
'reset.confirmPassword': 'Confirmar contraseña',
'reset.saveBtn': 'Cambiar contraseña',
'reset.saving': 'Guardando...',
'reset.passwordsDontMatch': 'Las contraseñas no coinciden',
'reset.successTitle': 'Contraseña actualizada',
'reset.successText': 'Tu contraseña se ha actualizado correctamente. Ya puedes iniciar sesión con tu nueva contraseña.',
'reset.goToLogin': 'Ir a iniciar sesión',
'reset.error': 'Error al restablecer la contraseña',
'reset.invalidToken': 'Enlace inválido o expirado. Solicita un nuevo restablecimiento de contraseña.',
// SavedNotifications // SavedNotifications
'savedNotifications.title': '🔔 Notificaciones Guardadas', 'savedNotifications.title': '🔔 Notificaciones Guardadas',
'savedNotifications.close': 'Cerrar', 'savedNotifications.close': 'Cerrar',
@@ -340,6 +390,8 @@ const es = {
'admin.pharmacy.apiNotFound': 'La app no pudo conectar con la API (404). Usa http://localhost:3000 con frontend y backend activos.', 'admin.pharmacy.apiNotFound': 'La app no pudo conectar con la API (404). Usa http://localhost:3000 con frontend y backend activos.',
'admin.pharmacy.geocodingNotFound': 'Servicio de geocodificación no encontrado. Actualiza el backend y reinícialo.', 'admin.pharmacy.geocodingNotFound': 'Servicio de geocodificación no encontrado. Actualiza el backend y reinícialo.',
'admin.pharmacy.searchFailed': 'Búsqueda fallida (HTTP', 'admin.pharmacy.searchFailed': 'Búsqueda fallida (HTTP',
'admin.pharmacy.alwaysOpen': '24 horas (abierta todo el día)',
'admin.pharmacy.confirmDisable24h': '¿Desactivar 24h? Se descartarán los horarios actuales.',
'admin.pharmacy.dayClosed': 'Marca un día como Cerrado si la farmacia no abre ese día.', 'admin.pharmacy.dayClosed': 'Marca un día como Cerrado si la farmacia no abre ese día.',
'admin.pharmacy.saveError': 'Error al guardar farmacia', 'admin.pharmacy.saveError': 'Error al guardar farmacia',
'admin.pharmacy.radius': 'Radio (m)', 'admin.pharmacy.radius': 'Radio (m)',
+85 -11
View File
@@ -27,14 +27,12 @@ function parseHours(raw) {
} }
} }
function findNextOpen(hours, now) { function findNextOpenInfo(hours, now) {
for (let offset = 1; offset <= 7; offset++) { for (let offset = 1; offset <= 7; offset++) {
const day = DAYS[(now.getDay() + offset) % 7]; const day = DAYS[(now.getDay() + offset) % 7];
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]; return { day, time: range[0], offset };
if (offset === 1) return `mañana a las ${openStr}`;
return `${DAY_LABELS[day]} a las ${openStr}`;
} }
} }
return null; return null;
@@ -42,14 +40,24 @@ function findNextOpen(hours, now) {
export function getOpenStatus(rawHours, now = new Date()) { export function getOpenStatus(rawHours, now = new Date()) {
const hours = parseHours(rawHours); const hours = parseHours(rawHours);
if (!hours) return null; if (!hours) return { status: 'unknown', label: 'Sin horario', labelKey: 'pharmacy.noHours', labelParams: {} };
if (isAlwaysOpen(hours)) {
return { status: 'open', label: 'Abierto 24h', labelKey: 'pharmacy.alwaysOpen', labelParams: {} };
}
const day = DAYS[now.getDay()]; const day = DAYS[now.getDay()];
const range = hours[day]; const range = hours[day];
if (!Array.isArray(range) || range.length !== 2) { if (!Array.isArray(range) || range.length !== 2) {
const next = findNextOpen(hours, now); const next = findNextOpenInfo(hours, now);
return { status: 'closed', label: next ? `Cerrado · Abre ${next}` : 'Cerrado' }; if (!next) {
return { status: 'closed', label: 'Cerrado', labelKey: 'pharmacy.closedAllDay', labelParams: {} };
}
if (next.offset === 1) {
return { status: 'closed', label: `Cerrado · Abre mañana a las ${next.time}`, labelKey: 'pharmacy.opensTomorrow', labelParams: { time: next.time } };
}
return { status: 'closed', label: `Cerrado · Abre el ${DAY_LABELS[next.day]} a las ${next.time}`, labelKey: 'pharmacy.opensDay', labelParams: { day: DAY_LABELS[next.day], time: next.time } };
} }
const openMins = parseHM(range[0]); const openMins = parseHM(range[0]);
@@ -59,15 +67,81 @@ 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: `Cerrado · Abre a las ${range[0]}` }; return { status: 'closed', label: `Cerrado · Abre a las ${range[0]}`, labelKey: 'pharmacy.opensAt', labelParams: { time: range[0] } };
} }
if (nowMins >= closeMins) { if (nowMins >= closeMins) {
const next = findNextOpen(hours, now); const next = findNextOpenInfo(hours, now);
return { status: 'closed', label: next ? `Cerrado · Abre ${next}` : 'Cerrado' }; if (!next) {
return { status: 'closed', label: 'Cerrado', labelKey: 'pharmacy.closedAllDay', labelParams: {} };
}
if (next.offset === 1) {
return { status: 'closed', label: `Cerrado · Abre mañana a las ${next.time}`, labelKey: 'pharmacy.opensTomorrow', labelParams: { time: next.time } };
}
return { status: 'closed', label: `Cerrado · Abre el ${DAY_LABELS[next.day]} a las ${next.time}`, labelKey: 'pharmacy.opensDay', labelParams: { day: DAY_LABELS[next.day], time: next.time } };
} }
return { status: 'open', label: `Abierto · Cierra a las ${range[1]}` }; return { status: 'open', label: `Abierto · Cierra a las ${range[1]}`, labelKey: 'pharmacy.openNow', labelParams: { time: range[1] } };
} }
export function emptyHours() { export function emptyHours() {
return { sun: null, mon: null, tue: null, wed: null, thu: null, fri: null, sat: null }; return { sun: null, mon: null, tue: null, wed: null, thu: null, fri: null, sat: null };
} }
export function emptyHoursDraft() {
const draft = {};
for (const day of DAYS) {
draft[day] = { open: '09:00', close: '21:00', closed: true };
}
return draft;
}
export function hoursToDraft(raw) {
let parsed = null;
if (raw) {
try { parsed = typeof raw === 'string' ? JSON.parse(raw) : raw; }
catch { parsed = null; }
}
const draft = {};
for (const day of DAYS) {
const v = parsed && parsed[day];
if (Array.isArray(v) && v.length === 2) {
draft[day] = { open: v[0], close: v[1], closed: false };
} else {
draft[day] = { open: '09:00', close: '21:00', closed: true };
}
}
return draft;
}
export function draftToHours(draft) {
const out = {};
let hasAny = false;
for (const day of DAYS) {
const d = draft[day];
if (d && !d.closed && d.open && d.close) {
out[day] = [d.open, d.close];
hasAny = true;
} else {
out[day] = null;
}
}
return hasAny ? out : null;
}
export function isAlwaysOpen(rawHours) {
const h = parseHours(rawHours);
if (!h) return false;
for (const d of DAYS) {
const r = h[d];
if (!Array.isArray(r) || r.length !== 2) return false;
if (r[0] !== '00:00' || r[1] !== '24:00') return false;
}
return true;
}
export function makeAlwaysOpenDraft() {
const draft = {};
for (const day of DAYS) {
draft[day] = { open: '00:00', close: '24:00', closed: false };
}
return draft;
}
+20 -3
View File
@@ -31,6 +31,7 @@ function PublicView({
const [userPosition, setUserPosition] = useState(null); const [userPosition, setUserPosition] = useState(null);
const [positionSource, setPositionSource] = useState(null); // 'profile' | 'browser' const [positionSource, setPositionSource] = useState(null); // 'profile' | 'browser'
const [sortByDistance, setSortByDistance] = useState(false); const [sortByDistance, setSortByDistance] = useState(false);
const [openNow, setOpenNow] = useState(false);
const [locating, setLocating] = useState(false); const [locating, setLocating] = useState(false);
const [locationError, setLocationError] = useState(''); const [locationError, setLocationError] = useState('');
@@ -177,8 +178,12 @@ function PublicView({
}; };
const displayedPharmacies = useMemo(() => { const displayedPharmacies = useMemo(() => {
if (!sortByDistance || !userPosition) return pharmacies; let filtered = pharmacies;
return [...pharmacies].sort((a, b) => { if (openNow) {
filtered = pharmacies.filter(p => p.is_open === true || p.opening_hours == null);
}
if (!sortByDistance || !userPosition) return filtered;
return [...filtered].sort((a, b) => {
if (a.latitude == null || a.longitude == null) return 1; if (a.latitude == null || a.longitude == null) return 1;
if (b.latitude == null || b.longitude == null) return -1; if (b.latitude == null || b.longitude == null) return -1;
return ( return (
@@ -186,7 +191,7 @@ function PublicView({
haversineKm(userPosition.lat, userPosition.lon, b.latitude, b.longitude) haversineKm(userPosition.lat, userPosition.lon, b.latitude, b.longitude)
); );
}); });
}, [pharmacies, sortByDistance, userPosition]); }, [pharmacies, openNow, sortByDistance, userPosition]);
/* ── Scanner → Search handoff ──────────────────────────── */ /* ── Scanner → Search handoff ──────────────────────────── */
function handleScanSelectMedicine(medicineName) { function handleScanSelectMedicine(medicineName) {
@@ -323,6 +328,13 @@ function PublicView({
{pharmacies.length > 0 && ( {pharmacies.length > 0 && (
<div className="pharmacy-controls"> <div className="pharmacy-controls">
<button
className={`open-now-toggle ${openNow ? 'active' : ''}`}
onClick={() => setOpenNow(o => !o)}
aria-pressed={openNow}
>
{openNow ? '🟢 Solo abiertas ahora' : '⏱ Mostrar solo abiertas ahora'}
</button>
<button <button
className={`sort-distance-button ${sortByDistance ? 'active' : ''}`} className={`sort-distance-button ${sortByDistance ? 'active' : ''}`}
onClick={handleSortByDistance} onClick={handleSortByDistance}
@@ -345,6 +357,11 @@ function PublicView({
</div> </div>
)} )}
{openNow && displayedPharmacies.length === 0 && (
<div className="open-now-empty">
<p>Ninguna farmacia abierta ahora — desactiva el filtro para ver todas.</p>
</div>
)}
<PharmacyMap pharmacies={displayedPharmacies} /> <PharmacyMap pharmacies={displayedPharmacies} />
<PharmacyList <PharmacyList
pharmacies={displayedPharmacies} pharmacies={displayedPharmacies}
@@ -0,0 +1,100 @@
.reset-container {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
padding: 1.5rem;
}
.reset-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 2rem 2.25rem 1.75rem;
width: 100%;
max-width: 360px;
box-shadow: 0 20px 60px rgba(28, 25, 23, 0.12);
}
.reset-card h2 {
margin: 0 0 0.25rem;
font-size: 1.35rem;
font-weight: 700;
color: var(--text-main);
text-align: center;
}
.reset-sub {
margin: 0 0 1.5rem;
font-size: 0.875rem;
color: var(--text-muted);
text-align: center;
}
.reset-field {
display: flex;
flex-direction: column;
gap: 0.35rem;
margin-bottom: 1rem;
}
.reset-field label {
font-size: 0.82rem;
font-weight: 600;
color: var(--text-muted);
}
.reset-field input {
padding: 0.6rem 0.85rem;
border: 1px solid var(--border);
border-radius: calc(var(--radius) - 4px);
background: var(--surface-muted);
color: var(--text-main);
font-size: 0.95rem;
outline: none;
transition: border-color 0.15s;
}
.reset-field input:focus {
border-color: var(--primary);
}
.reset-field input:disabled {
opacity: 0.6;
}
.reset-error {
font-size: 0.82rem;
color: var(--error);
margin: 0.25rem 0 0.75rem;
text-align: center;
}
.reset-btn {
width: 100%;
background: var(--primary);
border: none;
color: var(--on-primary);
padding: 0.65rem 1.35rem;
border-radius: 999px;
cursor: pointer;
font-size: 0.95rem;
font-weight: 600;
transition: opacity 0.15s;
margin-top: 0.5rem;
}
.reset-btn:hover:not(:disabled) {
opacity: 0.88;
}
.reset-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.reset-icon {
text-align: center;
font-size: 2.5rem;
margin-bottom: 0.75rem;
}
@@ -0,0 +1,109 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from '../i18n';
import './ResetPasswordView.css';
function ResetPasswordView({ token, onReset }) {
const { t } = useTranslation();
const [password, setPassword] = useState('');
const [confirm, setConfirm] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState(false);
useEffect(() => {
if (!token) setError(t('reset.invalidToken'));
}, [token, t]);
async function handleSubmit(e) {
e.preventDefault();
if (password.length < 8) {
setError(t('login.passwordError'));
return;
}
if (password !== confirm) {
setError(t('reset.passwordsDontMatch'));
return;
}
setLoading(true);
setError('');
try {
const res = await fetch('/api/auth/reset-password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token, password }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setError(data.error || t('reset.error'));
return;
}
setSuccess(true);
} catch {
setError(t('login.networkError'));
} finally {
setLoading(false);
}
}
if (success) {
return (
<div className="reset-container">
<div className="reset-card">
<div className="reset-icon">✅</div>
<h2>{t('reset.successTitle')}</h2>
<p>{t('reset.successText')}</p>
<button type="button" className="reset-btn" onClick={onReset}>
{t('reset.goToLogin')}
</button>
</div>
</div>
);
}
return (
<div className="reset-container">
<div className="reset-card">
<h2>{t('reset.title')}</h2>
<p className="reset-sub">{t('reset.subtitle')}</p>
<form onSubmit={handleSubmit} noValidate>
<div className="reset-field">
<label htmlFor="reset-password">{t('reset.newPassword')}</label>
<input
id="reset-password"
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
autoComplete="new-password"
disabled={loading}
minLength={8}
placeholder="········"
/>
</div>
<div className="reset-field">
<label htmlFor="reset-confirm">{t('reset.confirmPassword')}</label>
<input
id="reset-confirm"
type="password"
value={confirm}
onChange={e => setConfirm(e.target.value)}
autoComplete="new-password"
disabled={loading}
minLength={8}
placeholder="········"
/>
</div>
{error && <p className="reset-error">{error}</p>}
<button
type="submit"
className="reset-btn"
disabled={loading || !password || !confirm}
>
{loading ? t('reset.saving') : t('reset.saveBtn')}
</button>
</form>
</div>
</div>
);
}
export default ResetPasswordView;
+6 -1
View File
@@ -296,6 +296,11 @@
gap: 0.75rem; gap: 0.75rem;
margin: 1rem 0 0.5rem; margin: 1rem 0 0.5rem;
flex-wrap: wrap; flex-wrap: wrap;
position: sticky;
top: 0;
z-index: 10;
background: var(--surface);
padding: 0.75rem 0;
} }
.sort-distance-button { .sort-distance-button {
@@ -312,7 +317,7 @@
.sort-distance-button:hover:not(:disabled) { .sort-distance-button:hover:not(:disabled) {
border-color: var(--primary); border-color: var(--primary);
color: var(--primary); color: #151c17;
} }
.sort-distance-button.active { .sort-distance-button.active {
+25 -3
View File
@@ -6,6 +6,7 @@ import PharmacyList from '../components/PharmacyList';
import PharmacyMap from '../components/PharmacyMap'; import PharmacyMap from '../components/PharmacyMap';
import { haversineKm, getUserPosition, hasCachedPosition } from '../utils/geo'; import { haversineKm, getUserPosition, hasCachedPosition } from '../utils/geo';
import { useTranslation } from '../i18n'; import { useTranslation } from '../i18n';
import { getOpenStatus } from '../utils/hours';
import './SearchView.css'; import './SearchView.css';
const suggestions = [ const suggestions = [
@@ -26,6 +27,7 @@ function SearchView({ currentUser, onLoginRequest, initialQuery = '', onNavigate
const [userPosition, setUserPosition] = useState(null); const [userPosition, setUserPosition] = useState(null);
const [positionSource, setPositionSource] = useState(null); const [positionSource, setPositionSource] = useState(null);
const [sortByDistance, setSortByDistance] = useState(false); const [sortByDistance, setSortByDistance] = useState(false);
const [openNow, setOpenNow] = useState(false);
const [locating, setLocating] = useState(false); const [locating, setLocating] = useState(false);
const [locationError, setLocationError] = useState(''); const [locationError, setLocationError] = useState('');
const [recentSearches, setRecentSearches] = useState([]); const [recentSearches, setRecentSearches] = useState([]);
@@ -208,8 +210,19 @@ function SearchView({ currentUser, onLoginRequest, initialQuery = '', onNavigate
}; };
const displayedPharmacies = useMemo(() => { const displayedPharmacies = useMemo(() => {
if (!sortByDistance || !userPosition) return pharmacies; let result = pharmacies;
return [...pharmacies].sort((a, b) => { if (openNow) {
result = result.filter((p) => {
if (p.is_open === true) return true;
if (p.is_open == null) {
const s = getOpenStatus(p.opening_hours);
return s && s.status === 'open';
}
return false;
});
}
if (!sortByDistance || !userPosition) return result;
return [...result].sort((a, b) => {
if (a.latitude == null || a.longitude == null) return 1; if (a.latitude == null || a.longitude == null) return 1;
if (b.latitude == null || b.longitude == null) return -1; if (b.latitude == null || b.longitude == null) return -1;
return ( return (
@@ -217,7 +230,7 @@ function SearchView({ currentUser, onLoginRequest, initialQuery = '', onNavigate
haversineKm(userPosition.lat, userPosition.lon, b.latitude, b.longitude) haversineKm(userPosition.lat, userPosition.lon, b.latitude, b.longitude)
); );
}); });
}, [pharmacies, sortByDistance, userPosition]); }, [pharmacies, sortByDistance, userPosition, openNow]);
return ( return (
<div className="search-view"> <div className="search-view">
@@ -359,6 +372,12 @@ function SearchView({ currentUser, onLoginRequest, initialQuery = '', onNavigate
{pharmacies.length > 0 && ( {pharmacies.length > 0 && (
<div className="pharmacy-controls"> <div className="pharmacy-controls">
<button
className={`sort-distance-button ${openNow ? 'active' : ''}`}
onClick={() => setOpenNow((v) => !v)}
>
{openNow ? t('pharmacy.filterOpenNowActive') : t('pharmacy.filterOpenNow')}
</button>
<button <button
className={`sort-distance-button ${sortByDistance ? 'active' : ''}`} className={`sort-distance-button ${sortByDistance ? 'active' : ''}`}
onClick={handleSortByDistance} onClick={handleSortByDistance}
@@ -388,6 +407,9 @@ function SearchView({ currentUser, onLoginRequest, initialQuery = '', onNavigate
)} )}
</div> </div>
)} )}
{openNow && pharmacies.length > 0 && displayedPharmacies.length === 0 && (
<div className="no-pharmacies">{t('pharmacy.filterNoResults')}</div>
)}
<PharmacyMap pharmacies={displayedPharmacies} /> <PharmacyMap pharmacies={displayedPharmacies} />
<PharmacyList <PharmacyList
+4
View File
@@ -8,6 +8,10 @@ MONGODB_URI=mongodb://localhost:27017/parapharmacy
# CORS # CORS
CORS_ORIGIN=http://localhost:3000 CORS_ORIGIN=http://localhost:3000
# Internal credentials (generate unique random values outside local tests)
INGEST_API_KEY=dev-ingest-key-change-me
ADMIN_API_KEY=dev-admin-key-change-me
# Rate Limiting # Rate Limiting
RATE_LIMIT_WINDOW_MS=60000 RATE_LIMIT_WINDOW_MS=60000
RATE_LIMIT_MAX=100 RATE_LIMIT_MAX=100
+5 -2
View File
@@ -30,8 +30,11 @@ ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
# Copy package files # Copy package files
COPY apps/parapharmacy-api/package*.json ./ COPY apps/parapharmacy-api/package*.json ./
# Install dependencies (production only) #Run npm install to avoid npm ci issues
RUN npm install --omit=dev RUN npm install
# Install dependencies from the committed lockfile (production only)
RUN npm ci --omit=dev
# Copy source code # Copy source code
COPY apps/parapharmacy-api/ . COPY apps/parapharmacy-api/ .
@@ -0,0 +1,59 @@
import { jest } from '@jest/globals'
process.env.NODE_ENV = 'test'
process.env.INGEST_API_KEY = 'ingest-test-key'
process.env.ADMIN_API_KEY = 'admin-test-key'
jest.unstable_mockModule('../src/models/Product.js', () => ({
default: {
search: jest.fn(async () => ({ results: [], total: 0, page: 1, pages: 0 })),
distinct: jest.fn(async () => []),
find: jest.fn(() => ({ sort: () => ({ skip: () => ({ limit: () => ({ lean: async () => [] }) }) }) })),
countDocuments: jest.fn(async () => 0),
},
}))
jest.unstable_mockModule('../src/scraper.js', () => ({
scrapeAll: jest.fn(async () => ({ total: 0 })),
}))
const { default: supertest } = await import('supertest')
const { default: app } = await import('../src/server.js')
describe('parapharmacy security', () => {
test('keeps public product search available without credentials', async () => {
const res = await supertest(app).get('/api/products/search?q=cream')
expect(res.status).toBe(200)
})
test.each(['/api/products', '/api/products/bulk', '/api/scrape'])(
'rejects unauthenticated mutation route %s',
async (path) => {
const res = await supertest(app).post(path).send({})
expect(res.status).toBe(401)
},
)
test('accepts a valid ingest key for product creation', async () => {
const res = await supertest(app)
.post('/api/products')
.set('Authorization', 'Bearer ingest-test-key')
.send({})
expect(res.status).toBe(400)
})
test('requires a separate admin key for product deletion', async () => {
const res = await supertest(app)
.delete('/api/products/507f1f77bcf86cd799439011')
.set('Authorization', 'Bearer ingest-test-key')
expect(res.status).toBe(403)
})
test('does not expose Swagger in production', async () => {
const original = process.env.NODE_ENV
process.env.NODE_ENV = 'production'
const res = await supertest(app).get('/api/docs')
process.env.NODE_ENV = original
expect(res.status).toBe(404)
})
})
@@ -0,0 +1,28 @@
import crypto from 'crypto';
function readPresentedKey(req) {
const authorization = req.get('authorization');
if (authorization?.startsWith('Bearer ')) return authorization.slice(7);
return req.get('x-service-key');
}
export function requireServiceKey(environmentVariable) {
return (req, res, next) => {
const expected = process.env[environmentVariable];
const supplied = readPresentedKey(req);
if (!expected) {
return res.status(503).json({ error: 'Service authentication is not configured' });
}
if (!supplied) return res.status(401).json({ error: 'Authentication required' });
const suppliedBuffer = Buffer.from(supplied);
const expectedBuffer = Buffer.from(expected);
const valid = suppliedBuffer.length === expectedBuffer.length
&& crypto.timingSafeEqual(suppliedBuffer, expectedBuffer);
if (!valid) return res.status(403).json({ error: 'Invalid service credentials' });
return next();
};
}
+8 -4
View File
@@ -1,5 +1,6 @@
import { Router } from 'express'; import { Router } from 'express';
import Product from '../models/Product.js'; import Product from '../models/Product.js';
import { requireServiceKey } from '../middleware/service-auth.js';
const router = Router(); const router = Router();
@@ -208,7 +209,7 @@ router.get('/:id', async (req, res) => {
* 201: * 201:
* description: Product created/updated * description: Product created/updated
*/ */
router.post('/', async (req, res) => { router.post('/', requireServiceKey('INGEST_API_KEY'), async (req, res) => {
try { try {
const { const {
name, name,
@@ -277,13 +278,16 @@ router.post('/', async (req, res) => {
* 200: * 200:
* description: Upsert results * description: Upsert results
*/ */
router.post('/bulk', async (req, res) => { router.post('/bulk', requireServiceKey('INGEST_API_KEY'), async (req, res) => {
try { try {
const { products } = req.body; const { products } = req.body;
if (!Array.isArray(products)) { if (!Array.isArray(products)) {
return res.status(400).json({ error: 'products must be an array' }); return res.status(400).json({ error: 'products must be an array' });
} }
if (products.length > 100) {
return res.status(413).json({ error: 'products exceeds the maximum batch size of 100' });
}
const results = { const results = {
created: 0, created: 0,
@@ -337,7 +341,7 @@ router.post('/bulk', async (req, res) => {
* 404: * 404:
* description: Product not found * description: Product not found
*/ */
router.put('/:id', async (req, res) => { router.put('/:id', requireServiceKey('ADMIN_API_KEY'), async (req, res) => {
try { try {
const product = await Product.findByIdAndUpdate( const product = await Product.findByIdAndUpdate(
req.params.id, req.params.id,
@@ -374,7 +378,7 @@ router.put('/:id', async (req, res) => {
* 404: * 404:
* description: Product not found * description: Product not found
*/ */
router.delete('/:id', async (req, res) => { router.delete('/:id', requireServiceKey('ADMIN_API_KEY'), async (req, res) => {
try { try {
const product = await Product.findByIdAndDelete(req.params.id); const product = await Product.findByIdAndDelete(req.params.id);
+13 -2
View File
@@ -1,12 +1,23 @@
import { Router } from 'express'; import { Router } from 'express';
import { scrapeAll } from '../scraper.js'; import { scrapeAll } from '../scraper.js';
import rateLimit from 'express-rate-limit';
import { requireServiceKey } from '../middleware/service-auth.js';
const router = Router(); const router = Router();
const scrapeLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
max: 5,
standardHeaders: true,
legacyHeaders: false,
});
// Trigger scraping // Trigger scraping
router.post('/scrape', async (req, res) => { router.post('/scrape', scrapeLimiter, requireServiceKey('INGEST_API_KEY'), async (req, res) => {
try { try {
const { queries = ['crema hidratante'], sources = ['promofarma'] } = req.body; const { queries = ['crema hidratante'], sources = ['promofarma'] } = req.body;
if (!Array.isArray(queries) || !Array.isArray(sources) || queries.length > 20 || sources.length > 10) {
return res.status(400).json({ error: 'queries and sources must be bounded arrays' });
}
console.log('[Scraper] Starting scrape...'); console.log('[Scraper] Starting scrape...');
console.log(`[Scraper] Queries: ${queries.join(', ')}`); console.log(`[Scraper] Queries: ${queries.join(', ')}`);
@@ -19,7 +30,7 @@ router.post('/scrape', async (req, res) => {
res.json(result); res.json(result);
} catch (error) { } catch (error) {
console.error('[Scraper] Error:', error.message); console.error('[Scraper] Error:', error.message);
res.status(500).json({ error: 'Scraping failed', message: error.message }); res.status(500).json({ error: 'Scraping failed' });
} }
}); });
+9 -7
View File
@@ -53,7 +53,7 @@ const swaggerSpec = swaggerJsdoc(swaggerOptions);
// Middleware // Middleware
app.use(cors(config.cors)); app.use(cors(config.cors));
app.use(express.json({ limit: '10mb' })); app.use(express.json({ limit: '1mb' }));
app.use(morgan('combined')); app.use(morgan('combined'));
// Rate limiting // Rate limiting
@@ -66,11 +66,13 @@ const limiter = rateLimit({
app.use(limiter); app.use(limiter);
// Swagger UI // Swagger UI
app.use('/api/docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec, { if (!['production', 'test'].includes(process.env.NODE_ENV)) {
explorer: true, app.use('/api/docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec, {
customCss: '.swagger-ui .topbar { display: none }', explorer: true,
customSiteTitle: 'FarmaFinder Parapharmacy API', customCss: '.swagger-ui .topbar { display: none }',
})); customSiteTitle: 'FarmaFinder Parapharmacy API',
}));
}
// Routes // Routes
app.use('/api/products', productsRouter); app.use('/api/products', productsRouter);
@@ -131,6 +133,6 @@ process.on('SIGINT', async () => {
process.exit(0); process.exit(0);
}); });
start(); if (process.env.NODE_ENV !== 'test') start();
export default app; export default app;
+1
View File
@@ -1,6 +1,7 @@
APP_NAME=PIP - Pharmacy Integration Platform APP_NAME=PIP - Pharmacy Integration Platform
APP_VERSION=0.1.0 APP_VERSION=0.1.0
DEBUG=false DEBUG=false
NODE_ENV=development
DATABASE_URL=postgresql+asyncpg://pip:pip-secret@localhost:5432/pip DATABASE_URL=postgresql+asyncpg://pip:pip-secret@localhost:5432/pip
DATABASE_POOL_SIZE=20 DATABASE_POOL_SIZE=20
+2 -2
View File
@@ -7,11 +7,11 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
WORKDIR /app WORKDIR /app
COPY pyproject.toml ./ COPY apps/pip-platform/pyproject.toml ./
RUN pip install --no-cache-dir --upgrade pip && \ RUN pip install --no-cache-dir --upgrade pip && \
pip install --no-cache-dir . pip install --no-cache-dir .
COPY . . COPY apps/pip-platform/ .
EXPOSE 8000 EXPOSE 8000
+6 -13
View File
@@ -7,14 +7,14 @@ services:
ports: ports:
- "8000:8000" - "8000:8000"
environment: environment:
DATABASE_URL: postgresql+asyncpg://pip:${PG_PASSWORD:-pip-secret}@postgres:5432/pip DATABASE_URL: postgresql+asyncpg://pip:${PG_PASSWORD:?PG_PASSWORD must be set}@postgres:5432/pip
REDIS_URL: redis://redis:6379/0 REDIS_URL: redis://redis:6379/0
RABBITMQ_URL: amqp://pip:${RABBITMQ_PASSWORD:-pip-secret}@rabbitmq:5672/pip RABBITMQ_URL: amqp://pip:${RABBITMQ_PASSWORD:?RABBITMQ_PASSWORD must be set}@rabbitmq:5672/pip
JWT_SECRET_KEY: ${JWT_SECRET_KEY:-change-me-in-production} JWT_SECRET_KEY: ${JWT_SECRET_KEY:?JWT_SECRET_KEY must be set}
NODE_ENV: production NODE_ENV: production
LOG_LEVEL: INFO LOG_LEVEL: INFO
LOG_JSON_FORMAT: "true" LOG_JSON_FORMAT: "true"
CORS_ORIGINS: '["*"]' CORS_ORIGINS: ${CORS_ORIGINS:?CORS_ORIGINS must be set}
OTEL_EXPORTER_OTLP_ENDPOINT: http://host.docker.internal:4317 OTEL_EXPORTER_OTLP_ENDPOINT: http://host.docker.internal:4317
OTEL_TRACES_ENABLED: "true" OTEL_TRACES_ENABLED: "true"
depends_on: depends_on:
@@ -45,9 +45,7 @@ services:
environment: environment:
POSTGRES_DB: pip POSTGRES_DB: pip
POSTGRES_USER: pip POSTGRES_USER: pip
POSTGRES_PASSWORD: ${PG_PASSWORD:-pip-secret} POSTGRES_PASSWORD: ${PG_PASSWORD:?PG_PASSWORD must be set}
ports:
- "5432:5432"
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
healthcheck: healthcheck:
@@ -73,8 +71,6 @@ services:
redis: redis:
image: redis:7-alpine image: redis:7-alpine
restart: unless-stopped restart: unless-stopped
ports:
- "6380:6379"
volumes: volumes:
- redis_data:/data - redis_data:/data
healthcheck: healthcheck:
@@ -94,11 +90,8 @@ services:
restart: unless-stopped restart: unless-stopped
environment: environment:
RABBITMQ_DEFAULT_USER: pip RABBITMQ_DEFAULT_USER: pip
RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASSWORD:-pip-secret} RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASSWORD:?RABBITMQ_PASSWORD must be set}
RABBITMQ_DEFAULT_VHOST: pip RABBITMQ_DEFAULT_VHOST: pip
ports:
- "5672:5672"
- "15672:15672"
volumes: volumes:
- rabbitmq_data:/var/lib/rabbitmq - rabbitmq_data:/var/lib/rabbitmq
healthcheck: healthcheck:
+6 -13
View File
@@ -7,14 +7,14 @@ services:
ports: ports:
- "8000:8000" - "8000:8000"
environment: environment:
DATABASE_URL: postgresql+asyncpg://pip:${PG_PASSWORD:-pip-secret}@postgres:5432/pip DATABASE_URL: postgresql+asyncpg://pip:${PG_PASSWORD:?PG_PASSWORD must be set}@postgres:5432/pip
REDIS_URL: redis://redis:6379/0 REDIS_URL: redis://redis:6379/0
RABBITMQ_URL: amqp://pip:${RABBITMQ_PASSWORD:-pip-secret}@rabbitmq:5672/pip RABBITMQ_URL: amqp://pip:${RABBITMQ_PASSWORD:?RABBITMQ_PASSWORD must be set}@rabbitmq:5672/pip
JWT_SECRET_KEY: ${JWT_SECRET_KEY:-change-me-in-production} JWT_SECRET_KEY: ${JWT_SECRET_KEY:?JWT_SECRET_KEY must be set}
NODE_ENV: production NODE_ENV: production
LOG_LEVEL: INFO LOG_LEVEL: INFO
LOG_JSON_FORMAT: "true" LOG_JSON_FORMAT: "true"
CORS_ORIGINS: '["*"]' CORS_ORIGINS: ${CORS_ORIGINS:?CORS_ORIGINS must be set}
# Re-routed to the shared Grafana Alloy collector on srv84-macos. # Re-routed to the shared Grafana Alloy collector on srv84-macos.
# host.docker.internal resolves to the Docker host gateway from # host.docker.internal resolves to the Docker host gateway from
# inside the container — works because this stack runs on the same # inside the container — works because this stack runs on the same
@@ -53,9 +53,7 @@ services:
environment: environment:
POSTGRES_DB: pip POSTGRES_DB: pip
POSTGRES_USER: pip POSTGRES_USER: pip
POSTGRES_PASSWORD: ${PG_PASSWORD:-pip-secret} POSTGRES_PASSWORD: ${PG_PASSWORD:?PG_PASSWORD must be set}
ports:
- "5432:5432"
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
healthcheck: healthcheck:
@@ -81,8 +79,6 @@ services:
redis: redis:
image: redis:7-alpine image: redis:7-alpine
restart: unless-stopped restart: unless-stopped
ports:
- "6380:6379"
volumes: volumes:
- redis_data:/data - redis_data:/data
healthcheck: healthcheck:
@@ -102,11 +98,8 @@ services:
restart: unless-stopped restart: unless-stopped
environment: environment:
RABBITMQ_DEFAULT_USER: pip RABBITMQ_DEFAULT_USER: pip
RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASSWORD:-pip-secret} RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASSWORD:?RABBITMQ_PASSWORD must be set}
RABBITMQ_DEFAULT_VHOST: pip RABBITMQ_DEFAULT_VHOST: pip
ports:
- "5672:5672"
- "15672:15672"
volumes: volumes:
- rabbitmq_data:/var/lib/rabbitmq - rabbitmq_data:/var/lib/rabbitmq
healthcheck: healthcheck:
+5
View File
@@ -62,6 +62,11 @@ plugins = ["pydantic.mypy"]
requires = ["hatchling"] requires = ["hatchling"]
build-backend = "hatchling.build" build-backend = "hatchling.build"
[dependency-groups]
dev = [
"pip-audit>=2.10.1",
]
[tool.hatch.build.targets.wheel] [tool.hatch.build.targets.wheel]
packages = ["src"] packages = ["src"]
@@ -1,3 +1,4 @@
from pydantic import model_validator
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -12,6 +13,7 @@ class Settings(BaseSettings):
APP_NAME: str = "PIP - Pharmacy Integration Platform" APP_NAME: str = "PIP - Pharmacy Integration Platform"
APP_VERSION: str = "0.1.0" APP_VERSION: str = "0.1.0"
DEBUG: bool = False DEBUG: bool = False
NODE_ENV: str = "development"
DATABASE_URL: str = "postgresql+asyncpg://pip:pip@localhost:5432/pip" DATABASE_URL: str = "postgresql+asyncpg://pip:pip@localhost:5432/pip"
DATABASE_POOL_SIZE: int = 20 DATABASE_POOL_SIZE: int = 20
@@ -50,6 +52,23 @@ class Settings(BaseSettings):
HEALTH_CHECK_CACHE_TTL: int = 10 HEALTH_CHECK_CACHE_TTL: int = 10
@model_validator(mode="after")
def validate_production_security(self):
if self.NODE_ENV.lower() != "production":
return self
if self.JWT_SECRET_KEY == "change-me-in-production":
raise ValueError("JWT_SECRET_KEY must be changed in production")
default_credentials = ("pip:pip@", "pip-secret")
if any(value in self.DATABASE_URL or value in self.RABBITMQ_URL for value in default_credentials):
raise ValueError("default database or broker credentials are not allowed in production")
if self.CORS_ALLOW_CREDENTIALS and "*" in self.CORS_ORIGINS:
raise ValueError("wildcard CORS_ORIGINS are not allowed with credentials in production")
return self
@property @property
def DATABASE_URL_SYNC(self) -> str: def DATABASE_URL_SYNC(self) -> str:
return self.DATABASE_URL.replace("+asyncpg", "+psycopg2", 1) return self.DATABASE_URL.replace("+asyncpg", "+psycopg2", 1)
@@ -29,7 +29,7 @@ class TestCircuitBreaker:
return await coro return await coro
import asyncio import asyncio
result = asyncio.get_event_loop().run_until_complete(_run()) result = asyncio.run(_run())
assert result == "ok" assert result == "ok"
def test_open_raises_service_unavailable(self): def test_open_raises_service_unavailable(self):
@@ -42,7 +42,7 @@ class TestCircuitBreaker:
import asyncio import asyncio
with pytest.raises(ServiceUnavailableException): with pytest.raises(ServiceUnavailableException):
asyncio.get_event_loop().run_until_complete(_run()) asyncio.run(_run())
def test_half_open_after_recovery_timeout(self): def test_half_open_after_recovery_timeout(self):
cb = CircuitBreaker(name="test", failure_threshold=1, recovery_timeout=0.01) cb = CircuitBreaker(name="test", failure_threshold=1, recovery_timeout=0.01)
@@ -126,7 +126,7 @@ class TestWithRetry:
nonlocal call_count nonlocal call_count
call_count += 1 call_count += 1
if call_count < 3: if call_count < 3:
raise Exception("temp fail") raise ConnectionError("temp fail")
return "ok" return "ok"
policy = RetryPolicy(max_attempts=3, base_delay=0.01, max_delay=0.1) policy = RetryPolicy(max_attempts=3, base_delay=0.01, max_delay=0.1)
@@ -141,7 +141,7 @@ class TestWithRetry:
async def _always_fail(): async def _always_fail():
nonlocal call_count nonlocal call_count
call_count += 1 call_count += 1
raise Exception("always fail") raise ConnectionError("always fail")
policy = RetryPolicy(max_attempts=2, base_delay=0.01, max_delay=0.1) policy = RetryPolicy(max_attempts=2, base_delay=0.01, max_delay=0.1)
with pytest.raises(Exception, match="always fail"): with pytest.raises(Exception, match="always fail"):
@@ -0,0 +1,41 @@
import pytest
from src.infrastructure.config.settings import Settings
def test_production_rejects_placeholder_jwt_secret():
with pytest.raises(ValueError, match="JWT_SECRET_KEY"):
Settings(
NODE_ENV="production",
JWT_SECRET_KEY="change-me-in-production",
_env_file=None,
)
def test_production_rejects_default_database_and_broker_credentials():
with pytest.raises(ValueError, match="credentials"):
Settings(
NODE_ENV="production",
JWT_SECRET_KEY="a-real-test-secret",
DATABASE_URL="postgresql+asyncpg://pip:pip@localhost:5432/pip",
RABBITMQ_URL="amqp://pip:pip@localhost:5672/pip",
_env_file=None,
)
def test_production_rejects_wildcard_credentialed_cors():
with pytest.raises(ValueError, match="CORS"):
Settings(
NODE_ENV="production",
JWT_SECRET_KEY="a-real-test-secret",
DATABASE_URL="postgresql+asyncpg://pip:real-password@db:5432/pip",
RABBITMQ_URL="amqp://pip:real-password@rabbitmq:5672/pip",
CORS_ORIGINS=["*"],
CORS_ALLOW_CREDENTIALS=True,
_env_file=None,
)
def test_development_keeps_local_defaults_usable():
settings = Settings(_env_file=None)
assert settings.NODE_ENV == "development"
+2532
View File
File diff suppressed because it is too large Load Diff
+84 -23
View File
@@ -2,6 +2,13 @@ services:
redis: redis:
image: redis:alpine image: redis:alpine
restart: unless-stopped restart: unless-stopped
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
networks:
- internal
postgres: postgres:
image: postgres:16-alpine image: postgres:16-alpine
@@ -9,7 +16,9 @@ services:
environment: environment:
POSTGRES_DB: farmafinder POSTGRES_DB: farmafinder
POSTGRES_USER: farmafinder POSTGRES_USER: farmafinder
POSTGRES_PASSWORD: ${PG_PASSWORD:-change-me-in-production} POSTGRES_PASSWORD: ${PG_PASSWORD:?PG_PASSWORD must be set}
networks:
- internal
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U farmafinder"] test: ["CMD-SHELL", "pg_isready -U farmafinder"]
interval: 5s interval: 5s
@@ -24,21 +33,26 @@ services:
context: . context: .
dockerfile: apps/backend/Dockerfile dockerfile: apps/backend/Dockerfile
restart: unless-stopped restart: unless-stopped
healthcheck:
test: ["CMD", "node", "-e", "require('http').get('http://localhost:3001/healthz', (r) => { process.exit(r.statusCode === 200 ? 0 : 1) })"]
interval: 10s
timeout: 5s
retries: 5
start_period: 15s
env_file: env_file:
- ./apps/backend/.env - ./apps/backend/.env
ports:
- "3001:3001"
environment: environment:
PORT: "3001" PORT: "3001"
NODE_ENV: production NODE_ENV: production
SESSION_SECRET: ${SESSION_SECRET:-change-me-in-production} SESSION_SECRET: ${SESSION_SECRET:?SESSION_SECRET must be set}
CORS_ORIGIN: ${CORS_ORIGIN:-https://farmacias.hacecalor.net} CORS_ORIGIN: ${CORS_ORIGIN:?CORS_ORIGIN must be set}
COOKIE_SECURE: ${COOKIE_SECURE:-false}
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://farmafinder:${PG_PASSWORD:?PG_PASSWORD must be set}@postgres:5432/farmafinder
# 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: farmafinder-backend
OTEL_EXPORTER_OTLP_ENDPOINT: http://host.docker.internal:4317 OTEL_EXPORTER_OTLP_ENDPOINT: http://host.docker.internal:4317
@@ -52,6 +66,8 @@ services:
depends_on: depends_on:
- redis - redis
- postgres - postgres
networks:
- internal
frontend: frontend:
image: git.hacecalor.net/ichitux/farmafinder-frontend:latest image: git.hacecalor.net/ichitux/farmafinder-frontend:latest
@@ -63,32 +79,51 @@ services:
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
healthcheck:
test: ["CMD", "wget", "-qO", "/dev/null", "http://localhost:80/"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
ports: ports:
- "4000:80" - "4000:80"
depends_on: depends_on:
- backend - backend
networks:
- public
- internal
# --- Observability exporters (scraped by the shared Prometheus on srv84-macos) --- # --- Observability exporters (scraped by the shared Prometheus on srv84-macos) ---
redis-exporter: redis-exporter:
image: oliver006/redis_exporter:latest image: oliver006/redis_exporter:latest
restart: unless-stopped restart: unless-stopped
ports: healthcheck:
- "9121:9121" test: ["CMD", "wget", "-qO", "/dev/null", "http://localhost:9121/metrics"]
interval: 15s
timeout: 5s
retries: 3
environment: environment:
REDIS_ADDR: redis://redis:6379 REDIS_ADDR: redis://redis:6379
REDIS_PASSWORD: ${REDIS_PASSWORD:-} REDIS_PASSWORD: ${REDIS_PASSWORD:-}
depends_on: depends_on:
- redis - redis
networks:
- internal
postgres-exporter: postgres-exporter:
image: prometheuscommunity/postgres-exporter:latest image: prometheuscommunity/postgres-exporter:latest
restart: unless-stopped restart: unless-stopped
ports: healthcheck:
- "9187:9187" test: ["CMD", "wget", "-qO", "/dev/null", "http://localhost:9187/metrics"]
interval: 15s
timeout: 5s
retries: 3
environment: environment:
DATA_SOURCE_NAME: postgresql://farmafinder:${PG_PASSWORD:-change-me-in-production}@postgres:5432/farmafinder?sslmode=disable DATA_SOURCE_NAME: postgresql://farmafinder:${PG_PASSWORD:?PG_PASSWORD must be set}@postgres:5432/farmafinder?sslmode=disable
depends_on: depends_on:
- postgres - postgres
networks:
- internal
# --- Parapharmacy API --- # --- Parapharmacy API ---
parapharmacy-api: parapharmacy-api:
@@ -99,13 +134,13 @@ services:
env_file: env_file:
- ./apps/parapharmacy-api/.env - ./apps/parapharmacy-api/.env
restart: unless-stopped restart: unless-stopped
ports:
- "3002:3002"
environment: environment:
PORT: "3002" PORT: "3002"
NODE_ENV: production NODE_ENV: production
MONGODB_URI: mongodb://mongodb:27017/parapharmacy MONGODB_URI: mongodb://mongodb:27017/parapharmacy
CORS_ORIGIN: ${CORS_ORIGIN:-https://farmacias.hacecalor.net} CORS_ORIGIN: ${CORS_ORIGIN:?CORS_ORIGIN must be set}
INGEST_API_KEY: ${INGEST_API_KEY:?INGEST_API_KEY must be set}
ADMIN_API_KEY: ${ADMIN_API_KEY:?ADMIN_API_KEY must be set}
healthcheck: healthcheck:
test: ["CMD", "node", "-e", "require('http').get('http://localhost:3002/api/health', (r) => { process.exit(r.statusCode === 200 ? 0 : 1) })"] test: ["CMD", "node", "-e", "require('http').get('http://localhost:3002/api/health', (r) => { process.exit(r.statusCode === 200 ? 0 : 1) })"]
interval: 10s interval: 10s
@@ -114,37 +149,55 @@ services:
start_period: 15s start_period: 15s
depends_on: depends_on:
- mongodb - mongodb
networks:
- internal
# --- Mailpit — SMTP server for development (captures all outgoing emails) ---
# Web UI: http://localhost:8025
# SMTP: smtp://localhost:1025 (no auth)
mailpit:
image: axllent/mailpit:latest
restart: unless-stopped
ports:
- "8025:8025" # Web UI
- "1025:1025" # SMTP
networks:
- internal
# --- MongoDB for Parapharmacy --- # --- MongoDB for Parapharmacy ---
mongodb: mongodb:
image: mongo:7 image: mongo:7
restart: unless-stopped restart: unless-stopped
ports: healthcheck:
- "27017:27017" test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping')"]
interval: 10s
timeout: 5s
retries: 5
start_period: 10s
volumes: volumes:
- mongodb_data:/data/db - mongodb_data:/data/db
networks:
- internal
# --- N8N Workflow Automation --- # --- N8N Workflow Automation ---
n8n: n8n:
image: n8nio/n8n:latest image: n8nio/n8n:latest
restart: unless-stopped restart: unless-stopped
ports:
- "5678:5678"
environment: environment:
# Owner account (skips /setup) # Owner account (skips /setup)
N8N_USER_MANAGEMENT_DISABLED: "false" N8N_USER_MANAGEMENT_DISABLED: "false"
N8N_OWNER_EMAIL: ${N8N_EMAIL:-admin@farmafinder.com} N8N_OWNER_EMAIL: ${N8N_EMAIL:-admin@farmafinder.com}
N8N_OWNER_PASSWORD: ${N8N_PASSWORD:-change-me} N8N_OWNER_PASSWORD: ${N8N_PASSWORD:?N8N_PASSWORD must be set}
# Auth # Auth
N8N_BASIC_AUTH_ACTIVE: "true" N8N_BASIC_AUTH_ACTIVE: "true"
N8N_BASIC_AUTH_USER: ${N8N_USER:-admin} N8N_BASIC_AUTH_USER: ${N8N_USER:-admin}
N8N_BASIC_AUTH_PASSWORD: ${N8N_PASSWORD:-change-me} N8N_BASIC_AUTH_PASSWORD: ${N8N_PASSWORD:?N8N_PASSWORD must be set}
# Database # Database
DB_TYPE: postgresdb DB_TYPE: postgresdb
DB_POSTGRESDB_HOST: postgres DB_POSTGRESDB_HOST: postgres
DB_POSTGRESDB_DATABASE: farmafinder DB_POSTGRESDB_DATABASE: farmafinder
DB_POSTGRESDB_USER: farmafinder DB_POSTGRESDB_USER: farmafinder
DB_POSTGRESDB_PASSWORD: ${PG_PASSWORD:-change-me-in-production} DB_POSTGRESDB_PASSWORD: ${PG_PASSWORD:?PG_PASSWORD must be set}
# Network # Network
N8N_HOST: localhost N8N_HOST: localhost
N8N_PORT: 5678 N8N_PORT: 5678
@@ -163,6 +216,8 @@ services:
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
networks:
- internal
# --- N8N Init: import + activate workflows BEFORE n8n starts, then seed DB --- # --- N8N Init: import + activate workflows BEFORE n8n starts, then seed DB ---
n8n-init: n8n-init:
@@ -173,9 +228,9 @@ services:
DB_POSTGRESDB_HOST: postgres DB_POSTGRESDB_HOST: postgres
DB_POSTGRESDB_DATABASE: farmafinder DB_POSTGRESDB_DATABASE: farmafinder
DB_POSTGRESDB_USER: farmafinder DB_POSTGRESDB_USER: farmafinder
DB_POSTGRESDB_PASSWORD: ${PG_PASSWORD:-change-me-in-production} DB_POSTGRESDB_PASSWORD: ${PG_PASSWORD:?PG_PASSWORD must be set}
N8N_OWNER_EMAIL: ${N8N_EMAIL:-admin@farmafinder.com} N8N_OWNER_EMAIL: ${N8N_EMAIL:-admin@farmafinder.com}
N8N_OWNER_PASSWORD: ${N8N_PASSWORD:-change-me} N8N_OWNER_PASSWORD: ${N8N_PASSWORD:?N8N_PASSWORD must be set}
volumes: volumes:
- n8n_data:/home/node/.n8n - n8n_data:/home/node/.n8n
- ./n8n/workflows:/home/node/workflows - ./n8n/workflows:/home/node/workflows
@@ -189,9 +244,15 @@ services:
condition: service_healthy condition: service_healthy
n8n: n8n:
condition: service_healthy condition: service_healthy
networks:
- internal
volumes: volumes:
backend_data: backend_data:
postgres_data: postgres_data:
mongodb_data: mongodb_data:
n8n_data: n8n_data:
networks:
public:
internal:
+481
View File
@@ -0,0 +1,481 @@
# Plan — Horarios de farmacias, filtro "Abiertas Ahora" y farmacias 24h
> **TL;DR**
> - **El parser OSM ya existe** (`apps/API/opening-hours-osm.js`) y cubre `24/7`, rangos, splits, wrap-around y días cerrados. Su output (`["00:00", "24:00"]` para 24/7) ya entra en BD; lo que falta es **interpretar bien `24:00`**, centralizar el cálculo "abierto/24h" en el backend, exponer un endpoint "isOpenNow" y añadir el filtro en la UI.
> - Tres pasos incrementales, cada uno verificable de forma aislada. Ninguno rompe lo que ya hay: el campo `opening_hours` se conserva como JSON string y el admin ya lo edita.
> - **TDD obligatorio** (red-green-refactor, ≥80% cobertura en lo que se añada) por la regla global de testing.
---
## Estado actual (lo que ya tenemos)
| Pieza | Ubicación | Estado |
|-------|-----------|--------|
| Parser OSM → JSON `{day:[open,close] \| null}` | `apps/API/opening-hours-osm.js` | Hecho + tests en `apps/backend/__tests__/opening-hours-osm.test.js` |
| Almacenamiento `pharmacies.opening_hours TEXT` | `apps/backend/server.js` (`initDatabase`, `serializeOpeningHours`) | Hecho (PG + SQLite con migración) |
| Ingestión desde OSM, n8n webhook y URL openData | `apps/API/*`, `apps/backend/farmacias-webhook-import.js` | Hecho — los tres caminos ya parsean horarios |
| `getOpenStatus()` frontend | `apps/frontend/src/utils/hours.js` | Parcial: NO trata `24:00` como 24h (lo trata como "medianoche", farmacia cerrada); usa idioma hardcoded (es) en labels |
| Editor de horarios en admin | `apps/frontend/src/components/admin/PharmacyManagement.jsx` | Hecho (filas Lun–Dom, open/close, toggle "Cerrado") |
| Badge "Abierto / Cerrado" en lista | `apps/frontend/src/components/PharmacyList.jsx` | Hecho vía `getOpenStatus()` |
| Filtro "Abiertas Ahora" | — | **No existe** |
| Etiqueta "24h" en lista/mapa | — | **No existe** |
| Endpoint que devuelva estado calculado en servidor | — | **No existe** (todo se calcula en cliente) |
**Diagnóstico clave:**
1. `parseOsmOpeningHours('24/7')` produce `["00:00", "24:00"]` para todos los días. `getOpenStatus()` en frontend llama `parseHM("24:00")` → `24*60+0 = 1440`. Como `nowMins < 1440` casi siempre es `true`, nunca se evalúa el caso 24h. Necesita un caso explícito.
2. `getOpenStatus()` calcula "ahora" en el navegador. Si el usuario cambia de zona horaria o el servidor de test no está en hora local, el resultado puede no coincidir con el esperado.
3. `opening_hours` se devuelve crudo en `/api/medicines/:nregistro/pharmacies` y `/api/pharmacies`. El frontend debe parsearlo cada vez (ya lo hace, pero sin tests frontend).
---
## Enfoque general
1. **Mover el cálculo de "está abierta / es 24h" al backend**, exponerlo en un único helper puro y testable. Devolver ese dato precalculado en los endpoints públicos para que el frontend no tenga que duplicar la lógica ni parchear el caso `24:00`.
2. **Filtrar en cliente** (no en servidor): el dataset de farmacias por búsqueda es pequeño (decenas, no miles), y el filtro debe combinarse con "ordenar por distancia". Mantener la lógica en un solo reducer/hook.
3. **24h como estado derivado** del JSON, no como columna nueva: si todos los días son `["00:00", "24:00"]`, la farmacia es 24h. Evita migración, evita sincronización.
4. **i18n primero**: mover los strings hardcoded de `hours.js` y `PharmacyList.jsx` a los locales (`es.js`, `ca.js`).
---
## Paso 1 — Helper de estado abierto/24h en el backend (TDD)
> **Objetivo:** un solo módulo `apps/backend/src/hours.js` con `isOpenNow(rawHours, now)` y `isAlwaysOpen(rawHours)` que sea la fuente de verdad. Tests Jest con casos explícitos para `24:00`, medianoche cruzando, splits (no usados en almacenamiento, pero defensivo), días cerrados.
### 1.1 Tests primero (RED)
**`apps/backend/__tests__/hours.test.js`** (nuevo)
```
describe('isOpenNow')
✓ returns null when raw is null/empty
✓ returns null when JSON is malformed
✓ open window covers now → { isOpen: true, kind: 'open', closesAt: '21:00' }
✓ before opening today → { isOpen: false, kind: 'before-open', opensAt: '09:00' }
✓ after closing today but opens tomorrow → { isOpen: false, kind: 'after-close', nextOpen: { day: 'mon', time: '09:00' } }
✓ closed all week → { isOpen: false, kind: 'closed', nextOpen: null }
✓ 24/7 (["00:00","24:00"]) → { isOpen: true, kind: '24h' }
✓ 24:00 close at 23:59:59 → open; at 24:00:00 → close, nextOpen: tomorrow
✓ midnight-crossing range: mon ["22:00","02:00"] — at 01:00 monday → open (special-case: subtract 1 day from now, check sunday range)
— implement: if closeMins <= openMins, treat the range as [open, close+24h]; check nowMins against that
✓ all days closed → kind 'closed'
describe('isAlwaysOpen')
✓ all 7 days ["00:00","24:00"] → true
✓ one day different → false
✓ one day null → false
✓ null input → false
```
**Aceptación:** `npm test --workspace=farma-clic-backend` corre los nuevos tests y todos pasan **antes** de tocar otros archivos.
### 1.2 Implementación (GREEN)
**`apps/backend/src/hours.js`** (nuevo)
```js
const DAYS = ['sun', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat'];
function parse(raw) {
if (!raw) return null;
if (typeof raw === 'object') return raw;
try { return JSON.parse(raw); } catch { return null; }
}
function toMin(hm) {
// accept "24:00" as 1440 only when minutes == 0
const [h, m] = String(hm).split(':').map(Number);
if (!Number.isFinite(h) || !Number.isFinite(m)) return null;
if (h === 24 && m === 0) return 1440;
if (h < 0 || h > 24 || m < 0 || m >= 60) return null;
return h * 60 + m;
}
export function isAlwaysOpen(rawHours) {
const h = parse(rawHours);
if (!h) return false;
for (const d of DAYS) {
const r = h[d];
if (!Array.isArray(r) || r.length !== 2) return false;
if (r[0] !== '00:00' || r[1] !== '24:00') return false;
}
return true;
}
export function isOpenNow(rawHours, now = new Date()) {
const h = parse(rawHours);
if (!h) return null;
if (isAlwaysOpen(h)) {
return { isOpen: true, kind: '24h' };
}
const day = DAYS[now.getDay()];
const range = h[day];
if (!Array.isArray(range) || range.length !== 2) {
return { isOpen: false, kind: 'closed', nextOpen: findNextOpen(h, now) };
}
const openM = toMin(range[0]);
const closeM = toMin(range[1]);
if (openM == null || closeM == null) return null;
const nowM = now.getHours() * 60 + now.getMinutes();
// Midnight-crossing: close <= open means the range spans past 24:00.
if (closeM <= openM) {
if (nowM >= openM) return { isOpen: true, kind: 'open', closesAt: range[1] };
// before open today — maybe it's open from yesterday's range
const yestKey = DAYS[(now.getDay() + 6) % 7];
const yest = h[yestKey];
if (Array.isArray(yest)) {
const yOpen = toMin(yest[0]);
const yClose = toMin(yest[1]);
if (yOpen != null && yClose != null && yClose <= yOpen && nowM < yClose) {
return { isOpen: true, kind: 'open', closesAt: yest[1] };
}
}
return { isOpen: false, kind: 'before-open', opensAt: range[0], nextOpen: null };
}
if (nowM < openM) {
return { isOpen: false, kind: 'before-open', opensAt: range[0], nextOpen: null };
}
if (nowM >= closeM) {
return { isOpen: false, kind: 'after-close', nextOpen: findNextOpen(h, now) };
}
return { isOpen: true, kind: 'open', closesAt: range[1] };
}
function findNextOpen(h, now) {
for (let off = 1; off <= 7; off++) {
const key = DAYS[(now.getDay() + off) % 7];
const r = h[key];
if (Array.isArray(r) && r.length === 2) {
return { day: key, time: r[0] };
}
}
return null;
}
```
### 1.3 Refactor (IMPROVE)
- Extraer `findNextOpen` como `export` y reusarlo en `apps/frontend/src/utils/hours.js` (vía copia local por ahora — son proyectos independientes). No mover al `apps/API/` para no introducir regresiones en el parser OSM, que ya está en producción.
- Añadir `coverage` config al `jest.config.js` para exigir 100% en `src/hours.js` (es un módulo puro, sin dependencias).
**Verificación (regla de evidence):**
```bash
npm test --workspace=farma-clic-backend -- --testPathPattern=hours
# Esperado: 10/10 tests pasados, coverage del nuevo módulo = 100%
```
---
## Paso 2 — Exponer el estado calculado en los endpoints públicos
> **Objetivo:** que `/api/medicines/:nregistro/pharmacies` y `/api/pharmacies` devuelvan `is_open` y `is_24h` precalculados, basados en la hora del **servidor**. Esto fija una única fuente de verdad y elimina el bug del frontend con `24:00`.
### 2.1 Tests primero (RED)
**Ampliar `apps/backend/__tests__/server.test.js`** (o crear `pharmacy-hours-endpoint.test.js`):
```
GET /api/medicines/:nregistro/pharmacies
✓ when DB has pharmacy with opening_hours = 24/7 JSON
→ response[0].is_open === true, is_24h === true
✓ when DB has pharmacy with closed day
→ response[0].is_open === false, is_24h === false
✓ when DB has pharmacy with null opening_hours
→ response[0].is_open === null, is_24h === false
```
Mockear `userDbAll` o sembrar una BD en memoria antes de cada test (ya se hace en `server.test.js` — seguir el mismo patrón).
### 2.2 Implementación (GREEN)
En `apps/backend/server.js`, en ambos endpoints (`/api/medicines/:id/pharmacies` y `/api/products/:source/:id/pharmacies` y `/api/pharmacies`), mapear las filas para añadir dos campos:
```js
import { isOpenNow, isAlwaysOpen } from './src/hours.js';
// tras obtener `pharmacies`:
const enriched = pharmacies.map(p => {
let isOpen = null;
let is24h = false;
if (p.opening_hours) {
const status = isOpenNow(p.opening_hours);
isOpen = status ? status.isOpen : null;
is24h = isAlwaysOpen(p.opening_hours);
}
return { ...p, is_open: isOpen, is_24h: is24h };
});
res.json(enriched);
```
Crear un helper local `enrichPharmacy(row)` para no duplicarlo tres veces.
### 2.3 Refactor (IMPROVE)
- Si el rendimiento fuera problema (no lo es con <1000 filas), cachear `isOpenNow` con un TTL de 60s por minuto actual. Por ahora se calcula en cada request; dejar un comentario `// TODO(cache): si el dataset crece, cache por minuto`.
**Verificación:**
```bash
npm test --workspace=farma-clic-backend
# Esperado: tests existentes verdes + los nuevos verdes
# Manual: curl a /api/medicines/<nregistro>/pharmacies y verificar is_open en JSON
```
---
## Paso 3 — Soporte nativo de farmacias 24h en admin y selector de horarios
> **Objetivo:** que el admin pueda marcar una farmacia como "24h" con un toggle que rellene los 7 días con `00:00–24:00`, y que el editor manual respete el shape exacto del parser OSM.
### 3.1 Tests primero (RED)
**`apps/frontend/src/components/admin/PharmacyManagement.test.jsx`** (nuevo, con `@testing-library/react` si está disponible, si no: tests de unidad sobre los helpers puros)
Tests del helper puro `hoursToDraft(raw)` y `draftToHours(draft)` (extraídos de `PharmacyManagement.jsx`):
```
hoursToDraft
✓ 24/7 JSON → all 7 days with closed: false, open: '00:00', close: '24:00'
✓ normal hours → mirror
✓ null → all days with closed: true, open: '09:00', close: '21:00' (default)
draftToHours
✓ 24/7 draft (all days closed:false, 00:00, 24:00) → all 7 days ["00:00","24:00"]
✓ mixed → matches input
✓ all closed → null
```
### 3.2 Refactor del admin (IMPROVE para testabilidad)
1. Mover `emptyHoursDraft`, `hoursToDraft`, `draftToHours` a `apps/frontend/src/utils/hours.js` (ya existe) y exportarlos.
2. En `PharmacyManagement.jsx`, importar desde el helper. Esto los hace testables sin renderizar el componente.
3. Añadir **toggle "24 horas"** encima del fieldset de horarios. Estado: `is24h`. Cuando se active, poblar `hoursDraft` con `00:00/24:00` en todos los días y `closed: false`. Cuando se desactive, volver a `emptyHoursDraft()` (pregunte `confirm()` antes de descartar).
4. CSS: el toggle debe ser visible y mostrar el estado actual. Estilo coherente con el resto del admin (`.hours-row--always-open`).
### 3.3 Verificación
```bash
npm test --workspace=farma-clic-frontend
# Esperado: tests verdes del helper + sin regresión en PharmacyList
# Manual: crear farmacia 24h desde admin → ver en /api/pharmacies que is_24h=true
```
---
## Paso 4 — Filtro "Abiertas Ahora" en la vista pública
> **Objetivo:** un toggle justo al lado del botón "Ordenar por distancia". Cuando esté activo, la lista y el mapa solo muestran farmacias con `is_open === true` (o `is_24h === true`). Si una farmacia no tiene horarios, se muestra siempre (no se penaliza al admin por no haber editado).
### 4.1 Tests primero (RED)
**`apps/frontend/src/views/PublicView.test.jsx`** (nuevo si no existe) o tests sobre un hook puro extraído.
Refactor sugerido: extraer un hook `usePharmacyFilters(pharmacies, { sortByDistance, userPosition, openNow })` que devuelva la lista filtrada y ordenada. Eso permite testearlo sin renderizar.
```
usePharmacyFilters
✓ openNow=false → todas las farmacias
✓ openNow=true → solo las con is_open === true
✓ openNow=true + pharmacy con opening_hours=null → incluida (no se filtra)
✓ openNow=true + is_24h=true → incluida
✓ sortByDistance=true → orden por km ascendente
✓ sortByDistance=true sin userPosition → orden estable (no rompe)
✓ sortByDistance=true + pharmacy sin lat/lng → al final
```
### 4.2 Implementación (GREEN)
**`apps/frontend/src/views/PublicView.jsx`**
1. Nuevo state: `const [openNow, setOpenNow] = useState(false);`
2. Nuevo botón en `.pharmacy-controls` (donde está "Ordenar por distancia"):
```jsx
<button
className={`open-now-toggle ${openNow ? 'active' : ''}`}
onClick={() => setOpenNow(o => !o)}
aria-pressed={openNow}
>
{openNow ? '🟢 Abiertas ahora' : '⏱ Mostrar solo abiertas ahora'}
</button>
```
3. Mover la lógica de `displayedPharmacies` al nuevo hook `usePharmacyFilters(pharmacies, { sortByDistance, userPosition, openNow })`.
4. Pasar `displayedPharmacies` a `<PharmacyMap />` y `<PharmacyList />` igual que ahora.
5. Si el filtro deja 0 resultados: mostrar mensaje "Ninguna farmacia abierta ahora — desactiva el filtro para ver todas".
### 4.3 Visualización del badge 24h
En `apps/frontend/src/components/PharmacyList.jsx`, dentro de `PharmacyCard`, añadir (cuando `pharmacy.is_24h === true`):
```jsx
<span className="pharmacy-badge pharmacy-badge--24h" aria-label="Abierta 24 horas">24h</span>
```
al lado del nombre, antes de la distancia.
En el popup de `PharmacyMap.jsx` (`apps/frontend/src/components/PharmacyMap.jsx`):
```jsx
{pharmacy.is_24h && <span className="map-badge-24h">24h</span>}
```
### 4.4 Verificación
```bash
npm test --workspace=farma-clic-frontend
# Manual:
# 1. Crear 2 farmacias: una 9-14h (cerrada ahora si son las 17h), otra 24h
# 2. Buscar un medicamento enlazado a las dos
# 3. Pulsar "Abiertas ahora" → solo aparece la 24h
# 4. Desactivar filtro → aparecen las dos
```
---
## Paso 5 — i18n de los labels de horario
> **Objetivo:** mover los strings hardcoded de `apps/frontend/src/utils/hours.js` ("Abierto · Cierra a las X", "Cerrado · Abre mañana…") a los locales `es.js` y `ca.js`. Idem para el nuevo "Abiertas ahora".
### 5.1 Tests / Tareas
1. Añadir claves en `apps/frontend/src/i18n/locales/es.js`:
```js
'pharmacy.openNow': 'Abierto · Cierra a las {{time}}',
'pharmacy.closedAllDay': 'Cerrado',
'pharmacy.opensAt': 'Cerrado · Abre a las {{time}}',
'pharmacy.opensTomorrow': 'Cerrado · Abre mañana a las {{time}}',
'pharmacy.opensDay': 'Cerrado · Abre el {{day}} a las {{time}}',
'pharmacy.alwaysOpen': 'Abierto 24h',
'pharmacy.filterOpenNow': 'Mostrar solo abiertas ahora',
'pharmacy.filterOpenNowActive': 'Solo abiertas ahora',
'pharmacy.badge24h': '24h',
'pharmacy.filterNoResults': 'Ninguna farmacia abierta ahora. Desactiva el filtro para ver todas.'
```
2. Replicar en `ca.js` con las traducciones catalanas correspondientes.
3. Refactor de `getOpenStatus()` para que devuelva `{ status, labelKey, labelParams }` en vez de strings listos, y que `PharmacyList.jsx` los pase por `t()`. Mantener compatibilidad: si no se puede traducir, devolver el string por defecto (defensivo).
### 5.2 Verificación
```bash
npm test --workspace=farma-clic-frontend
# Manual: cambiar idioma a catalán (ca) y verificar badges
```
---
## Paso 6 — Verificación end-to-end (cierre del plan)
> Antes de declarar el plan completo, ejecutar el set de verificaciones del repo (regla "evidence-based completion" — sin evidencia, no se afirma éxito).
### 6.1 Backend
```bash
# Linter
cd apps/backend && npx eslint .
# Tests
npm test --workspace=farma-clic-backend
# Cobertura ≥ 80% en modules nuevos:
# apps/backend/src/hours.js → 100%
# apps/backend/__tests__/hours.test.js → 100%
# endpoints pharmacy hours tests → ≥ 80%
```
### 6.2 Frontend
```bash
# Linter
cd apps/frontend && npx eslint .
# Tests
npm test --workspace=farma-clic-frontend
# Cobertura ≥ 80% en:
# apps/frontend/src/utils/hours.js (tras mover helpers de admin) → 100%
# apps/frontend/src/views/PublicView.jsx → ≥ 80%
# apps/frontend/src/components/PharmacyList.jsx → ≥ 80%
# apps/frontend/src/components/admin/PharmacyManagement.jsx → ≥ 80%
```
### 6.3 Integración real (Docker)
```bash
docker compose up --build -d
docker compose exec backend npm run seed # crea 3 farmacias: 24h, 9-21h, fines de semana
curl -s http://localhost:3001/api/pharmacies | jq '.[0] | {name, is_open, is_24h}'
# Esperado: 24h farmacia con is_open=true, is_24h=true
```
### 6.4 E2E manual (smoke)
1. Abrir `http://localhost:4000`, buscar "ibuprofeno", seleccionar.
2. Verificar: las 3 farmacias aparecen con su badge de estado correcto.
3. Pulsar "Mostrar solo abiertas ahora" → solo las abiertas.
4. Pulsar "Ordenar por distancia" → reordena, badge 24h visible.
5. Abrir el popup del mapa de la 24h → etiqueta "24h" visible.
### 6.5 Regresión
- `apps/backend/__tests__/opening-hours-osm.test.js` → sigue verde (no tocamos el parser).
- `apps/backend/__tests__/server.test.js` → sigue verde; los nuevos endpoints se añaden sin romper los existentes.
- `apps/frontend/src/components/PharmacyList.jsx` actual: si `is_24h` es `undefined` (respuesta legacy de la BD sin enriquecer), debe renderizarse igual que antes (compatibilidad).
---
## Orden de implementación recomendado
| Paso | Tiempo estimado | Bloquea |
|------|----------------|---------|
| 1. Helper backend `src/hours.js` (TDD) | 1.5 h | Pasos 2 y 4 |
| 2. Enriquecer endpoints públicos (TDD) | 1 h | Paso 4 (frontend necesita el campo) |
| 3. Admin 24h (refactor + test) | 1 h | — (paralelo a 4) |
| 4. Filtro "Abiertas ahora" + badge 24h (TDD) | 2 h | — |
| 5. i18n (es + ca) | 0.5 h | — |
| 6. Verificación + cobertura | 0.5 h | Cierre |
| **Total** | **~6.5 h** | |
Pasos 1 y 3 pueden hacerse en paralelo. Paso 5 puede entramparse con 4.
---
## Riesgos y mitigaciones
| Riesgo | Mitigación |
|--------|-----------|
| `parseOsmOpeningHours` cambia en producción y rompe el shape | Tests existentes en `opening-hours-osm.test.js` ya cubren los casos. No tocar. |
| Hora del servidor difiere del navegador del usuario | Devolver `server_now` en la respuesta (`{ is_open, server_now, server_tz }`) para que la UI pueda mostrar "estado según la hora del servidor". Si en el futuro se quiere client-side, aceptar `?at=<iso>` y calcular contra ese timestamp. |
| Performance: 1000 farmacias × 7 días de parseo por request | Trivial. Si crece, cachear por minuto (ver 2.3). |
| `24:00` no es `HH:mm` válido ISO y rompe parsers terceros | Mantener el shape interno pero, al exportar, escribir un campo derivado `is_24h` (booleano) en el JSON público. El shape interno sigue siendo el mismo. |
| i18n incompleta en ca.js | Mantener strings en `es.js` como fallback. Si una clave falta en `ca.js`, el `useTranslation` ya cae al idioma por defecto. |
---
## Archivos a crear / modificar (resumen)
**Crear:**
- `apps/backend/src/hours.js`
- `apps/backend/__tests__/hours.test.js`
- `apps/backend/__tests__/pharmacy-hours-endpoint.test.js`
- `apps/frontend/src/views/PublicView.test.jsx` (o `usePharmacyFilters.test.js` si se extrae el hook)
- `apps/frontend/src/components/admin/PharmacyManagement.test.jsx` (si testing-library está disponible; si no, tests sobre los helpers puros)
**Modificar:**
- `apps/backend/server.js` — enriquecer `pharmacies` en 3 endpoints; importar `src/hours.js`
- `apps/frontend/src/views/PublicView.jsx` — state `openNow`, hook de filtros, botón en `.pharmacy-controls`
- `apps/frontend/src/components/PharmacyList.jsx` — badge 24h, pasar por i18n
- `apps/frontend/src/components/PharmacyMap.jsx` — badge 24h en popup
- `apps/frontend/src/components/admin/PharmacyManagement.jsx` — toggle 24h, importar helpers
- `apps/frontend/src/utils/hours.js` — mover `emptyHoursDraft`/`hoursToDraft`/`draftToHours` desde admin; añadir `isAlwaysOpen`; cambiar `getOpenStatus` para devolver labelKey/params
- `apps/frontend/src/i18n/locales/es.js` — nuevas claves
- `apps/frontend/src/i18n/locales/ca.js` — nuevas claves
**No tocar:**
- `apps/API/opening-hours-osm.js` (parser OSM en producción)
- `apps/backend/farmacias-webhook-import.js` (ya parsea correctamente)
---
## Preguntas abiertas (a resolver antes de implementar)
1. **¿Soportar farmacias que cruzan medianoche?** (ej. `mon ["22:00","02:00"]`). El shape actual lo permite pero `getOpenStatus` actual no lo trata. **Propuesta:** sí, implementar el caso en el helper del backend (tests ya incluidos en 1.1).
2. **¿Borrar `getOpenStatus` del frontend o mantenerlo como fallback?** **Propuesta:** mantenerlo, pero hacer que la UI use `pharmacy.is_open` y `pharmacy.is_24h` cuando estén presentes (del backend) y solo caiga al cálculo local si faltan. Sin duplicación de lógica.
3. **¿Mostrar farmacia sin horarios cuando el filtro "abiertas ahora" está activo?** **Propuesta:** sí, mostrarlas (asumimos que no sabemos = no filtramos). Documentarlo en la UI.
4. **¿Refrescar el estado cada minuto?** (por si la lista queda abierta mucho tiempo y cambia el estado). **Propuesta:** no por ahora; si la lista se refresca al cambiar de medicamento, basta. Si se observa uso prolongado sin refresco, añadir `setInterval` de 60s para recalcular.
Cuando confirmes el plan (o pidas ajustes) lo paso a implementación siguiendo TDD estricto.
+94
View File
@@ -0,0 +1,94 @@
# Horarios de farmacias — Implementación
## Resumen
Sistema completo para gestionar, calcular y filtrar horarios de apertura de farmacias. Implementado siguiendo el plan en `docs/PLAN-HORARIOS.md`.
## Lo implementado
### Backend (`apps/backend/`)
| Archivo | Qué hace |
|---------|----------|
| `src/hours.js` | Helper puro: `isOpenNow(rawHours, now?)` y `isAlwaysOpen(rawHours)`. Soporta 24/7, rangos normales, cruce de medianoche, días cerrados, `24:00` como 1440 min. |
| `server.js` | Los 3 endpoints públicos (`/api/medicines/:id/pharmacies`, `/api/products/:source/:id/pharmacies`, `/api/pharmacies`) devuelven `is_open` (boolean o null) e `is_24h` (boolean) precalculados con la hora del servidor. Helper `enrichPharmacy(row)`. |
### Frontend (`apps/frontend/`)
| Archivo | Qué hace |
|---------|----------|
| `src/utils/hours.js` | `getOpenStatus()` refactorizado: devuelve `labelKey`/`labelParams` para i18n + `label` legacy. Nuevos: `emptyHoursDraft()`, `hoursToDraft()`, `draftToHours()` (movidos desde admin), `isAlwaysOpen()`, `makeAlwaysOpenDraft()`. |
| `src/views/PublicView.jsx` | Nuevo estado `openNow`. Botón "Mostrar solo abiertas ahora" en `.pharmacy-controls`. Filtro en `displayedPharmacies` (incluye farmacias sin horarios). Mensaje si 0 resultados. |
| `src/components/PharmacyList.jsx` | Badge `24h` junto al nombre. Texto de estado usa `t()` con claves i18n. |
| `src/components/PharmacyMap.jsx` | Badge `24h` en popup del marcador. |
| `src/components/admin/PharmacyManagement.jsx` | Toggle "24 horas" en el editor de horarios. Al activarlo, rellena todos los días con `00:00-24:00`. Al desactivarlo, `confirm()` antes de descartar. |
| `src/i18n/locales/es.js` | Nuevas claves: `pharmacy.openNow`, `pharmacy.closedAllDay`, `pharmacy.opensAt`, `pharmacy.opensTomorrow`, `pharmacy.opensDay`, `pharmacy.alwaysOpen`, `pharmacy.filterOpenNow`, `pharmacy.filterOpenNowActive`, `pharmacy.badge24h`, `pharmacy.filterNoResults`, `admin.pharmacy.alwaysOpen`, `admin.pharmacy.confirmDisable24h`. |
| `src/i18n/locales/ca.js` | Traducciones catalanas de todas las claves nuevas. |
### Tests
| Archivo | Tests |
|---------|-------|
| `apps/backend/__tests__/hours.test.js` | 22 tests — `isAlwaysOpen` (6), `isOpenNow` (16): 24/7, normal, cerrado, cruce medianoche, null, malformed JSON. |
| `apps/backend/__tests__/pharmacy-hours-endpoint.test.js` | 4 tests — verifica `is_open`/`is_24h` en respuesta JSON para 24h, normal, null, múltiples. |
| `apps/frontend/src/App.test.jsx` | 6 tests existentes — sin regresión. |
| `apps/frontend/src/utils/notifications.test.js` | 1 test existente — sin regresión. |
## Cómo funciona
1. **Almacenamiento**: `pharmacies.opening_hours` como TEXT JSON. Shape: `{ mon: ["09:00","21:00"], tue: null, ... }`. 24/7 → todos los días `["00:00","24:00"]`.
2. **Cálculo en servidor**: Cada request a endpoints públicos ejecuta `isOpenNow()` con la hora del servidor. El frontend recibe `is_open` e `is_24h` ya calculados.
3. **Cálculo en cliente**: `getOpenStatus()` existe como fallback si `is_open` no está presente (datos legacy).
4. **Filtro**: Cliente-side. Farmacias sin horarios (`opening_hours = null`) no se filtran.
5. **Admin**: El toggle 24h rellena los 7 días. El editor manual permite día por día.
## Puntos de mejora futuros
### Pendientes del plan original
- [ ] **Refresco automático cada 60s**: Si la página permanece abierta mucho tiempo, el estado "abierto/cerrado" puede quedar desactualizado. Añadir `setInterval` de 60s en `PublicView.jsx` para recalcular o re-fetch.
- [ ] **Cache server-side**: Si el dataset de farmacias crece (>1000), cachear `isOpenNow` con TTL de 1 minuto por minuto actual. Comentario `TODO(cache)` ya está en `server.js`.
### UI/UX
- [ ] **Tooltip explicativo**: Al pasar el ratón sobre el badge "24h", mostrar "Abierta las 24 horas del día".
- [ ] **Color en el filtro**: El botón "Abiertas ahora" ganaría con un icono verde intermitente o un cambio de color más evidente.
- [ ] **Separar horas de apertura/cierre**: Actualmente el editor de admin usa `<input type="time">` que no acepta `24:00` como valor. Para crear una farmacia 24h hay que usar el toggle. El input manual no permite escribir `24:00`.
- [ ] **Ordenación combinada**: "Abiertas ahora" + "Ordenar por distancia" deberían priorizar las abiertas pero ordenadas por distancia. Actualmente primero filtra, luego ordena.
### Técnicos
- [ ] **Zona horaria explícita**: El servidor usa su hora local. Devolver `server_now` y `server_tz` en la respuesta para que la UI pueda mostrar "según hora del servidor". Ver pregunta abierta #1 en el plan.
- [ ] **Tests de integración real**: Los tests de backend usan SQLite en memoria. Con PostgreSQL real los endpoints deben comportarse igual.
- [ ] **Parser OSM 24/7 → `24:00`**: El parser de OSM ya produce `["00:00","24:00"]` para `24/7`. Si en el futuro OSM cambia el formato, actualizar solo `opening-hours-osm.js`. No tocar nada más (los helpers son agnósticos al formato de entrada).
- [ ] **Cobertura frontend**: Los helpers puros de `hours.js` no tienen tests unitarios. Añadir tests para `isAlwaysOpen()` frontend, `emptyHoursDraft()`, `hoursToDraft()`, `draftToHours()`, `makeAlwaysOpenDraft()`.
### Admin
- [ ] **Vista previa de horarios**: En la lista de farmacias del admin, mostrar un resumen "L-V 9:00-21:00, S 9:00-14:00" o "24h" en vez del JSON crudo.
- [ ] **Importación masiva con horarios**: El importador OSM ya trae `opening_hours`. El importador de datos abiertos también si el JSON incluye el campo. Verificar que los tres caminos de ingesta sigan parseando correctamente tras los cambios.
### Mobile (`apps/frontend-mobile/`)
- [ ] **Los mismos cambios en la app móvil**: El frontend móvil tiene su propia copia de i18n (`apps/frontend-mobile/src/i18n/locales/es.js` y `ca.js`) con menos claves. No se ha tocado. Habría que añadir las mismas claves `pharmacy.*` y replicar la lógica de filtrado/badge.
## Archivos creados/modificados
**Creados:**
- `apps/backend/__tests__/pharmacy-hours-endpoint.test.js`
- `docs/horarios.md` (este)
**Modificados:**
- `apps/backend/server.js` — import `src/hours.js`, helper `enrichPharmacy`, 3 endpoints enriquecidos
- `apps/frontend/src/utils/hours.js` — `getOpenStatus()` con i18n, +5 nuevas exportaciones
- `apps/frontend/src/views/PublicView.jsx` — filtro openNow
- `apps/frontend/src/components/PharmacyList.jsx` — badge 24h, i18n en estado
- `apps/frontend/src/components/PharmacyMap.jsx` — badge 24h en popup
- `apps/frontend/src/components/admin/PharmacyManagement.jsx` — toggle 24h, import desde hours.js
- `apps/frontend/src/i18n/locales/es.js` — 12 nuevas claves
- `apps/frontend/src/i18n/locales/ca.js` — 12 nuevas claves
**No tocados (intencionalmente):**
- `apps/API/opening-hours-osm.js` — parser OSM en producción
- `apps/backend/farmacias-webhook-import.js` — ya parsea correctamente
- `apps/frontend-mobile/` — requiere移植 manual
@@ -0,0 +1,49 @@
# Dependency Hardening Baseline
**Baseline date:** 2026-07-22
**Branch:** `security/dependency-hardening-2026-07-22`
**Lockfile:** root `package-lock.json`, installed with `npm ci`
## Toolchain
- Node.js `v22.22.1` (the production Dockerfiles target Node 20 and 24)
- npm `9.2.0`
- Python `3.14.4`
- uv `0.5.9`
- Docker `29.6.1`
- Docker Compose `v5.3.1`
The local Node version produces an expected engine warning for `@zxing/library@0.23.0`, which requires Node 24 or newer. Production and CI must use the declared Node image/version rather than this local Node 22 runtime.
## Audit counts
Reports were generated online and saved outside the repository. Counts are advisory snapshots, not a substitute for the CI gate.
| Scope | Critical | High | Moderate | Low | Total |
|---|---:|---:|---:|---:|---:|
| Workspace root | 3 | 13 | 60 | 3 | 79 |
| Backend workspace | 1 | 10 | 41 | 2 | 54 |
| Parapharmacy API | 0 | 2 | 0 | 1 | 3 |
| Frontend workspace | 1 | 6 | 20 | 1 | 28 |
| Scraper workspace | 0 | 1 | 0 | 0 | 1 |
The parapharmacy and scraper reports resolve through the root workspace lockfile. Their package-local audit invocation fails without a package-local lockfile; the workspace-scoped audit is the authoritative current result.
## Direct dependency families selected for review
- Backend OpenTelemetry: `@opentelemetry/auto-instrumentations-node ^0.52.0`, exporters/SDKs primarily on `^0.55.0`, resources/API families on `^1.x`.
- Backend native dependencies: `bcrypt ^5.1.1`, `sqlite3 ^5.1.6`, and `connect-sqlite3 ^0.9.16`.
- Parapharmacy: `mongoose ^8.8.0`, `puppeteer ^22.0.0`; current installed Puppeteer is `22.15.0`.
- Scraper: `puppeteer ^24.40.0`, `puppeteer-extra ^3.3.6`, and stealth plugin `^2.11.2`.
- Frontend: `vite ^5.0.8`, `vitest ^1.6.0`, `vite-plugin-pwa ^1.3.0`, and Grafana Faro/OpenTelemetry packages on the `^1.x` family.
## Upgrade order and known risks
1. Resolve Python dependencies and audit them independently.
2. Pin runtime images and verify lockfile-enforced Docker builds.
3. Upgrade backend OpenTelemetry as one family, then native bcrypt/SQLite packages.
4. Upgrade parapharmacy Mongoose/`fast-uri` and scraper Puppeteer separately.
5. Upgrade frontend Vite/Rollup/esbuild/Vitest without enabling Vitest UI.
6. Re-run all audits and record any residual advisory with production reachability, mitigation, owner, and expiry/review date.
Do not run `npm audit fix --force`: the baseline reports breaking-version fixes for several families, including OpenTelemetry, SQLite, and Vite/Vitest. Each such upgrade requires its own compatibility test and commit.
@@ -0,0 +1,132 @@
# FarmaFinder Security Audit Report
**Audit date:** 2026-07-22
**Scope:** repository source, configuration, environment files present on disk, Docker Compose files, Git history, Node lockfiles, and Python dependency declarations.
**Status:** **Not ready for an unrestricted production deployment.**
## Executive summary
The most urgent risks are configuration and access-control issues, not evidence of a secret committed to Git:
1. Local ignored `.env` files contain credential-shaped production values. The values must be treated as exposed if they were ever copied to a server, shared, or used in a public environment. Rotate the Expo access token and VAPID private key immediately, and rotate any database, session, n8n, or webhook credentials that may have shared the same deployment.
2. Production Docker Compose has known-secret fallbacks such as `change-me-in-production`, `change-me`, and `pip-secret`. A deployment can therefore start with predictable credentials when variables are missing.
3. The parapharmacy API exposes product create, bulk upsert, update, delete, and scraper-trigger endpoints without authentication. This is a direct integrity and availability risk.
4. The backend silently falls back to a hard-coded session secret. A missing secret must fail closed in production.
5. The verified npm audit reports 79 advisories in the workspace, including 3 critical and 13 high. The most important direct production-adjacent findings include vulnerable OpenTelemetry packages, `bcrypt`/`tar` transitive issues, and the vulnerable `ws`/`brace-expansion` chains. The frontend test toolchain includes a critical Vitest advisory; it must not be exposed as a server in any environment.
## Findings
### F-01 — Secrets present in local environment files
**Severity:** Critical
**Evidence:** `apps/backend/.env` and `apps/parapharmacy-api/.env` exist locally and are ignored by Git. The backend file contains a non-empty Expo access token and VAPID private key; it also contains database/session/ingest credential fields. Values are intentionally not reproduced here.
**Impact:** Push credentials, session signing, database access, or workflow ingestion may be compromised. A VAPID private key can impersonate the application for web push; an Expo token can send notifications through the project account.
**Action:** Rotate the Expo token and VAPID key now. Rotate all non-development credentials in those files. Review CI, deployment hosts, shell history, password managers, and logs for copies. Keep secrets in the deployment secret store only.
### F-02 — Predictable production secret fallbacks
**Severity:** Critical
**Evidence:** `docker-compose.yml:12,34,41,89,137,141,147,176,178` uses defaults including `change-me-in-production`, `change-me`, and a derived PostgreSQL URL. `apps/backend/server.js:83-85` falls back to `farma-clic-secret-key-change-in-production`. `apps/pip-platform/src/infrastructure/config/settings.py:16,24,26` contains default database, broker, and JWT credentials. The PIP Compose files also default `JWT_SECRET_KEY` at `apps/pip-platform/docker-compose.yml:13` and `docker-compose.runtime.yml:13`.
**Impact:** Missing environment injection can enable session forgery, JWT forgery, database access, or n8n takeover.
**Action:** Remove secret defaults. Add startup validation that rejects `NODE_ENV=production`/production deployments when required secrets are absent or match known placeholders. Use `${VAR:?VAR must be set}` in Compose for required values.
### F-03 — Unauthenticated parapharmacy write/delete/scrape APIs
**Severity:** Critical
**Evidence:** `apps/parapharmacy-api/src/routes/products.js:280-320,340-390` exposes bulk write, update, and delete routes without auth. `apps/parapharmacy-api/src/routes/scraper.js:7-24` exposes a scraper trigger without auth. `apps/parapharmacy-api/src/server.js:55-70` applies only global CORS, JSON parsing, logging, and rate limiting.
**Impact:** An unauthenticated caller can poison or delete the catalog, trigger expensive Puppeteer scraping, and cause resource exhaustion.
**Action:** Protect write/delete/scrape routes with a dedicated ingestion/admin API key or service-to-service authentication. Apply a strict body/item limit and a separate low-rate limiter to scraper execution. Return generic errors without echoing scraper exception messages.
### F-04 — Hard-coded backend session secret fallback
**Severity:** High
**Evidence:** `apps/backend/server.js:83-85` uses a fixed fallback secret. The session cookie is HTTP-only and SameSite=Lax (`:87-91`), which is good, but `secure` is opt-in at `COOKIE_SECURE=true`.
**Impact:** Anyone who knows the repository can forge sessions when the deployment omits `SESSION_SECRET`; if `COOKIE_SECURE` is omitted in HTTPS production, cookies may be sent over an accidental HTTP path.
**Action:** Fail startup when production lacks a strong `SESSION_SECRET`; force secure cookies in production and set `app.set('trust proxy', ...)` only to the known proxy topology.
### F-05 — Public infrastructure ports and unauthenticated observability/admin surfaces
**Severity:** High
**Evidence:** `docker-compose.yml:29-30,66-67,75-76,86-89,102-103,122-123,131-132` publishes backend, frontend, exporters, parapharmacy API, MongoDB, and n8n ports. Swagger is mounted at `/api/docs` in `apps/parapharmacy-api/src/server.js:64-69` without an environment guard.
**Impact:** Databases, exporters, n8n, and internal APIs may be reachable from the host network or internet. Swagger reveals mutation endpoints and operational details.
**Action:** Bind internal services to the Docker network or loopback only. Publish only the reverse proxy/frontend. Restrict n8n and Swagger to an authenticated admin network or disable them in production.
### F-06 — Verified npm dependency advisories
**Severity:** Critical/High/Moderate by package and exposure
**Evidence:** `npm audit --json` completed with network access on 2026-07-22.
| Lockfile / scope | Critical | High | Moderate | Low | Total |
|---|---:|---:|---:|---:|---:|
| Workspace root | 3 | 13 | 60 | 3 | 79 |
| Backend | 1 | 11 | 41 | 3 | 56 |
| Frontend | 1 | 3 | 18 | 0 | 22 |
| Parapharmacy API | 0 | 2 | 0 | 1 | 3 |
| Scraper | 0 | 1 | 0 | 0 | 1 |
Notable verified chains include:
- backend: `@opentelemetry/auto-instrumentations-node` high-severity Prometheus exporter crash advisory; `bcrypt` through `@mapbox/node-pre-gyp`/`tar`; vulnerable `ws` and `uuid` transitive chains;
- frontend: critical `vitest` advisory when its UI server is exposed, plus vulnerable Vite/esbuild/Rollup and Grafana Faro/OpenTelemetry chains;
- parapharmacy API: `fast-uri`, `brace-expansion`, and `body-parser` advisories;
- scraper: `brace-expansion` high-severity DoS advisory.
**Action:** Apply targeted non-breaking patches first where available, then upgrade major-version families in isolated branches with tests. Do not use `npm audit fix --force` blindly; the audit indicates major-version changes for several fixes.
### F-07 — Dependency hygiene and likely-unused candidates
**Severity:** Medium (maintenance/security surface)
**Evidence:** A static repository scan found direct dependencies with no source import/reference outside manifests/lockfiles:
- backend: `barcode-detector`, `@opentelemetry/exporter-logs-otlp-grpc`, `@opentelemetry/sdk-logs`, `@opentelemetry/sdk-trace-base`;
- frontend: `@grafana/faro-web-sdk`;
- mobile: `expo-dev-client`, `expo-linking`, `react-native-screens`, `react-native-worklets` (some may be required by Expo/native autolinking);
- parapharmacy API: `jest`, `supertest` (test tooling is declared but no tests were found in that package).
This scan is not proof of unused status because Expo, native autolinking, configuration, and instrumentation can consume packages indirectly. Confirm each candidate with package-manager dependency tracing and a clean build before removal. No package was removed during this audit.
### F-08 — Build/runtime supply-chain hygiene
**Severity:** High
**Evidence:** `docker-compose.yml:22,57,73,84,95,120,129,169` uses mutable `:latest` image tags. `apps/parapharmacy-api/Dockerfile` uses `npm install --omit=dev` instead of `npm ci`; `apps/backend/Dockerfile` uses `npm ci`.
**Impact:** Rebuilds are not reproducible and can silently pull changed or compromised images/dependency resolutions.
**Action:** Pin images by version and digest, use lockfile-enforced `npm ci` for every Node image, and add image/dependency scanning to CI.
### F-09 — Mobile/API keys and telemetry endpoints need restriction
**Severity:** Medium
**Evidence:** `apps/frontend-mobile/google-services.json:18` contains a Firebase API key. This type of key is normally public, but must be restricted by package/bundle identity and API scope in Google Cloud/Firebase. Mobile and frontend env examples point telemetry at public or host endpoints.
**Action:** Verify Firebase key restrictions, avoid treating public client configuration as a secret, and enforce collector authentication/rate limits so telemetry endpoints cannot be abused.
## Positive controls observed
- Git status was clean before this audit.
- `.gitignore` excludes `.env`, dependency directories, build output, and Android signing material.
- Git history inspection found environment examples and placeholder values, but no tracked actual `.env` file or the local Expo/VAPID values.
- Backend uses parameterized SQL for the inspected user/session paths, bcrypt password hashing, HTTP-only cookies, admin middleware, and route-specific rate limiting.
- Production backend and frontend use container builds, and backend uses `npm ci --omit=dev`.
## Verification and limitations
- Source/configuration and Git-history checks were read-only.
- Online npm audit was executed with network access on 2026-07-22; advisory counts are a point-in-time result and should be rerun in CI.
- No production host, running container, database, secret manager, cloud account, or external firewall was inspected.
- No Python lockfile exists for `apps/pip-platform`; the Python audit therefore covered declarations and defaults, not resolved CVEs. Add a lockfile and run `pip-audit`/`uv audit` in CI.
- The audit did not prove that every endpoint is unreachable externally; exposure depends on deployment networking.
## Recommended deployment decision
Do not deploy the current configuration publicly until F-01 through F-05 are addressed. Dependency upgrades and supply-chain pinning should follow immediately. The implementation sequence is documented in [the hotfix plan](../superpowers/plans/2026-07-22-security-hotfixes.md).
@@ -0,0 +1,101 @@
# FarmaFinder Security Dependency and Release Hardening Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Remove the remaining high/critical dependency and supply-chain risks, make Python and container builds reproducible, and establish deployment-blocking security verification after the configuration and access-control hotfixes.
**Architecture:** Upgrade dependencies in isolated package-family batches while preserving the existing runtime security controls. Resolve Python dependencies into a committed `uv.lock`, pin container inputs by immutable digests, and run dependency, image, and secret scans in CI. Any remaining advisory is recorded with its path, exploitability, mitigation, and owner.
**Tech Stack:** npm workspaces/package-lock, Node.js 20/24, Python 3.13, uv, pip-audit, Docker Compose, Trivy, Gitleaks, Jest, Vitest, pytest.
---
## Files and responsibilities
- Create `docs/security/2026-07-22-dependency-hardening.md`: audit baseline, selected versions/digests, residual-advisory decisions, and verification evidence.
- Create `apps/pip-platform/uv.lock`: resolved, hash-pinned Python dependency graph.
- Modify `apps/backend/package.json`, `apps/parapharmacy-api/package.json`, `apps/frontend/package.json`, `apps/scraper/package.json`, and the root `package-lock.json` for isolated dependency batches.
- Modify both Node Dockerfiles, Compose files, and deployment docs for immutable images and lockfile-enforced installs.
- Create `.github/workflows/security.yml`: reproducible installs, tests, audits, secret scanning, image scanning, and Compose checks.
## Baseline to preserve
The preceding hotfix plan already added production secret validation, service-key middleware, protected parapharmacy mutations, internal Compose networking, production-safe Swagger behavior, and security regression tests. Do not revert those controls. The last online audit reported 56 advisories for the backend/workspace scope and 3 for the parapharmacy API; counts must be refreshed before each batch.
### Task 1: Establish an auditable dependency baseline
**Files:** Create `docs/security/2026-07-22-dependency-hardening.md`.
- [ ] Run `npm ci` from the repository root, then run `npm audit --json`, `npm audit --prefix apps/backend --json`, `npm audit --prefix apps/parapharmacy-api --json`, `npm audit --prefix apps/frontend --json`, and `npm audit --prefix apps/scraper --json`, saving reports only under `/tmp` and never recording secret values.
- [ ] Record counts, direct versions, Node/npm/Python/uv/Docker versions, dependency paths, available fixes, and whether each finding reaches a production image.
- [ ] Confirm no unrelated lockfile rewrite exists with `git diff --check` and `git status --short`.
- [ ] Commit the ledger with `git add docs/security/2026-07-22-dependency-hardening.md && git commit -m "docs: record dependency hardening baseline"`.
### Task 2: Create and verify the Python lockfile
**Files:** Create `apps/pip-platform/uv.lock`; update PIP deployment docs if present.
- [ ] Run `cd apps/pip-platform && uv lock`; do not hand-edit generated lock content.
- [ ] Recreate from the lock with `uv sync --frozen --extra dev`, then run `uv run pytest -q` and `uv run pip-audit --strict`.
- [ ] If `pip-audit` is absent, add it to the development dependency group, rerun `uv lock`, and repeat the frozen sync. Record each Python advisory with package, path, reachability, and owner.
- [ ] Commit with `git add apps/pip-platform/uv.lock && git commit -m "build: lock pip platform dependencies"`.
### Task 3: Pin runtime container inputs immutably
**Files:** Root/PIP Compose files, both Node Dockerfiles, and the dependency-hardening ledger.
- [ ] Replace every runtime `:latest` image with an approved release. Resolve digests with `docker buildx imagetools inspect redis:7-alpine`, `postgres:16-alpine`, `mongo:7`, the approved n8n release, the approved Redis exporter release, and the approved PostgreSQL exporter release.
- [ ] Replace each reference with `repository:tag@sha256:` followed by the exact digest returned by `docker buildx imagetools inspect`, record the UTC update date and approver, and verify the digest is for the deployment platform.
- [ ] Ensure each Node Dockerfile copies its matching `package.json` and lockfile before `npm ci --omit=dev`; build both images and run a startup/config smoke test.
- [ ] Commit the image/Dockerfile batch separately so it can be reverted without undoing dependency code.
### Task 4: Upgrade backend dependency families independently
**Files:** `apps/backend/package.json`, root `package-lock.json`, existing backend tests.
- [ ] Run `npm ci` and `npm test --prefix apps/backend -- --runInBand` before changing versions.
- [ ] Upgrade all direct OpenTelemetry packages as one compatible family: API, auto-instrumentations, exporters, instrumentation-pino, resources, SDKs, and semantic conventions. Do not upgrade only `auto-instrumentations-node`.
- [ ] Regenerate only through npm, inspect the lockfile for unrelated upgrades, and rerun the backend suite.
- [ ] In a separate batch, upgrade `bcrypt` and `sqlite3`; verify native installs under Node 20 and 24, session/database tests, `npm ls tar node-gyp`, and `npm audit --prefix apps/backend --audit-level=high`.
- [ ] Never use `npm audit fix --force`; any major upgrade requires a compatibility note and review. Commit each family separately.
### Task 5: Upgrade parapharmacy and scraper dependency families
**Files:** `apps/parapharmacy-api/package.json`, `apps/scraper/package.json`, root `package-lock.json`, security tests.
- [ ] Upgrade Mongoose within its supported major, regenerate the lockfile, and confirm `npm ls fast-uri` resolves outside the audited range.
- [ ] Run `npm test --prefix apps/parapharmacy-api -- --runInBand __tests__/security.test.js` and `npm audit --prefix apps/parapharmacy-api --audit-level=high`.
- [ ] Upgrade scraper Puppeteer dependencies only after recording the current executable path. Preserve `PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true` and `/usr/bin/chromium`; verify with `node -e "console.log(process.env.PUPPETEER_EXECUTABLE_PATH || '/usr/bin/chromium')"`.
- [ ] Run scraper module-load/available tests and commit Mongoose and scraper batches separately.
### Task 6: Upgrade frontend tooling without exposing Vitest UI
**Files:** `apps/frontend/package.json`, root `package-lock.json`, Vite/Vitest config, CI workflow.
- [ ] Confirm no production or CI command uses Vitest UI with `rg -n "vitest( --ui|.*ui)" apps/frontend package.json .github`.
- [ ] Upgrade Vite, Rollup/esbuild, and Vitest as a compatible toolchain; keep React plugin, jsdom, and testing-library versions compatible with the selected Vitest major.
- [ ] Regenerate the lockfile and run `npm test --prefix apps/frontend`, `npm run build --prefix apps/frontend`, and `npm audit --prefix apps/frontend --audit-level=high`.
- [ ] Record any development-only residual advisory and commit this batch separately.
### Task 7: Add deployment-blocking CI gates
**Files:** Create `.github/workflows/security.yml`.
- [ ] Add pull-request/protected-branch jobs for `npm ci` plus all Node tests/builds; workspace/app `npm audit --audit-level=high`; `uv sync --frozen`, pytest, and `pip-audit --strict`; Gitleaks full-history scanning; Trivy high/critical image scanning; and root/PIP Compose rendering.
- [ ] The Compose job must use fixture values and assert that only intended frontend/API ports are published, no internal database/exporter ports are published, and no production-secret `${...:-placeholder}` fallback remains.
- [ ] Build production images with committed lockfiles, scan image digests, upload JSON reports as artifacts, and never upload `.env` files or secret values.
- [ ] Pin third-party actions to reviewed immutable commit SHAs and grant read-only repository permissions. Commit with `git add .github/workflows/security.yml && git commit -m "ci: enforce dependency and image security gates"`.
### Task 8: Run final verification and residual-advisory review
**Files:** Update the dependency-hardening ledger only if verification exposes a regression or documented exception.
- [ ] Run `npm ci`, backend tests, parapharmacy security tests, frontend tests/build, `uv sync --frozen --extra dev`, `uv run pytest`, and `uv run pip-audit --strict`.
- [ ] Render all Compose files with production-like fixture variables; inspect for placeholders, mutable tags, public database/exporter ports, and missing required secrets.
- [ ] Build all production images and black-box test public GETs, 401 unauthenticated mutations, 403 invalid keys, absent production Swagger, and absent host-published internal infrastructure.
- [ ] Re-run all audits. Every remaining high/critical item must list package, dependency path, production reachability, exploitability, mitigation, owner, and review/expiry date; unexplained high/critical findings block deployment.
- [ ] Run `git diff --check`, confirm no real `.env` is tracked, attach command counts to the ledger, and commit the final evidence.
## Self-review
This plan covers the remaining audit findings: dependency-family upgrades, Python locking/auditing, immutable images, lockfile-enforced Docker installs, secret scanning, image scanning, frontend/Vitest exposure, and residual-advisory review. It deliberately avoids forced major upgrades and keeps each risky family in a reversible batch.
@@ -0,0 +1,97 @@
# FarmaFinder Security Hotfixes Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Remove production-blocking secret, access-control, network-exposure, and dependency risks identified by the 2026-07-22 security audit.
**Architecture:** Make production configuration fail closed, centralize service authentication for parapharmacy ingestion operations, and keep internal infrastructure private behind the reverse proxy. Upgrade dependency families in isolated batches with lockfile and runtime verification.
**Tech Stack:** Node.js 20/24, Express, express-session, MongoDB/Mongoose, Docker Compose, npm lockfiles, Python/Pydantic settings, Jest/Vitest.
---
## Files and responsibilities
- Modify `apps/backend/server.js`: production configuration validation, secure session cookie defaults, and any shared service-auth helper integration.
- Create `apps/backend/src/config/required-env.js` and `apps/backend/src/middleware/service-auth.js`: fail-closed environment validation and constant-time service-key verification.
- Modify `apps/parapharmacy-api/src/server.js`, `apps/parapharmacy-api/src/routes/products.js`, and `apps/parapharmacy-api/src/routes/scraper.js`: protect mutation/scrape routes, tighten limits, and hide Swagger in production.
- Modify `docker-compose.yml` and `apps/pip-platform/docker-compose*.yml`: remove secret fallbacks, stop publishing internal ports, and pin image/dependency behavior.
- Modify `apps/pip-platform/src/infrastructure/config/settings.py`: reject placeholder production secrets and wildcard credentialed CORS.
- Modify `apps/backend/.env.example`, `.env.example`, and relevant README/docs: document required secret generation without real values.
- Add/extend `apps/backend/__tests__/server.test.js` and create `apps/parapharmacy-api/__tests__/security.test.js`: regression coverage for fail-closed behavior and route protection.
- Update relevant `package.json` and lockfiles only through package-manager commands after the security tests are in place.
### Task 1: Rotate and inventory credentials before code changes
**Files:** Deployment secret store and local ignored `.env` files; no repository source changes required.
- [ ] Revoke the Expo access token found in the local environment and issue a replacement with the minimum project scope.
- [ ] Generate a new VAPID key pair and replace both public/private values wherever deployed.
- [ ] Rotate `SESSION_SECRET`, `PG_PASSWORD`, `REDIS_PASSWORD`, `N8N_PASSWORD`, `INGEST_API_KEY`, MongoDB credentials, and PIP `JWT_SECRET_KEY` if their values were used outside local development.
- [ ] Search deployment logs, CI variables, shell history, and backups for the old values; record the revocation date without writing secret values to Git.
- [ ] Confirm `git ls-files` contains no actual `.env` file before continuing.
### Task 2: Add fail-closed production configuration validation
**Files:** Create `apps/backend/src/config/required-env.js`; modify `apps/backend/server.js`, `apps/pip-platform/src/infrastructure/config/settings.py`, and both PIP Compose files.
- [ ] Add a backend validator that rejects production startup when `SESSION_SECRET`, `CORS_ORIGIN`, and `PG_URL`/`PG_PASSWORD` are missing or equal to a known placeholder. Use `crypto.timingSafeEqual` only for fixed-length key comparisons; validation itself should compare exact placeholder strings.
- [ ] Replace the backend fallback at `server.js:84` with a required value from the validator.
- [ ] Set `cookie.secure` to `true` whenever `NODE_ENV === 'production'`, while retaining an explicit development override only for local HTTP.
- [ ] Replace every Compose `${SECRET:-placeholder}` expression with `${SECRET:?SECRET must be set}` for production-required secrets.
- [ ] In PIP settings, reject `JWT_SECRET_KEY=change-me-in-production`, reject default database/broker credentials in production, and reject `CORS_ORIGINS=['*']` when credentials are enabled.
- [ ] Add tests that start configuration with missing/placeholder secrets and assert a clear startup error.
### Task 3: Authenticate parapharmacy ingestion and admin mutation routes
**Files:** Create `apps/parapharmacy-api/src/middleware/service-auth.js`; modify `apps/parapharmacy-api/src/server.js`, `src/routes/products.js`, and `src/routes/scraper.js`; create `apps/parapharmacy-api/__tests__/security.test.js`.
- [ ] Require `INGEST_API_KEY` for `POST /api/products`, `POST /api/products/bulk`, and `POST /api/scrape` using `Authorization: Bearer <key>` or a dedicated internal header. Reject missing/malformed keys with 401 and compare supplied keys in constant time.
- [ ] Require a separate admin credential for `PUT /api/products/:id` and `DELETE /api/products/:id`; do not reuse a public frontend session unless the API is intentionally integrated with that session.
- [ ] Add a route-specific limiter for `/api/scrape`, cap queries/sources to bounded arrays, cap product bulk size, and lower `express.json` to the smallest limit required by real payloads.
- [ ] Return `{ error: 'Scraping failed' }` without `error.message` in production.
- [ ] Mount Swagger only when `NODE_ENV !== 'production'` or protect it with the same admin control.
- [ ] Test 401 for unauthenticated mutation/scrape requests, 403/401 for invalid keys, and successful behavior for a valid key. Test that public GET search endpoints remain available.
### Task 4: Close infrastructure network exposure
**Files:** Modify `docker-compose.yml`, `apps/pip-platform/docker-compose.yml`, `apps/pip-platform/docker-compose.runtime.yml`, and `apps/frontend/nginx.conf` as needed.
- [ ] Remove host `ports` for Redis, PostgreSQL, MongoDB, exporters, and n8n; use `127.0.0.1:host:container` only when local operator access is explicitly required.
- [ ] Publish only the intended frontend/reverse-proxy port and route internal API traffic through the proxy or a private Docker network.
- [ ] Add a dedicated internal network and keep database/exporter services off any public-facing network.
- [ ] Add production security headers at the reverse proxy, including HSTS only when HTTPS is guaranteed, and verify proxy headers are preserved.
- [ ] Disable or protect `/api/docs` in production and verify an external request cannot reach n8n, MongoDB, PostgreSQL, Redis, or exporter ports.
### Task 5: Make builds reproducible and remove mutable image inputs
**Files:** `apps/backend/Dockerfile`, `apps/parapharmacy-api/Dockerfile`, `docker-compose.yml`, PIP Compose files, CI workflow files.
- [ ] Replace `npm install --omit=dev` in the parapharmacy Dockerfile with `npm ci --omit=dev`.
- [ ] Pin all runtime image tags to approved versions and digests; record the update date in the deployment documentation.
- [ ] Add CI checks for `npm ci`, `npm audit --audit-level=high`, image vulnerability scanning, and secret scanning.
- [ ] Generate a Python lockfile and run `pip-audit` or `uv audit` against resolved dependencies.
### Task 6: Upgrade vulnerable dependency families in batches
**Files:** `package.json`, `package-lock.json`, each app `package.json`/lockfile touched by the audit.
- [ ] First update patch/minor-compatible vulnerable transitive packages and regenerate lockfiles with `npm install --package-lock-only`; inspect the diff for unrelated upgrades.
- [ ] Upgrade backend OpenTelemetry packages as one compatible family, then `bcrypt`/SQLite-related packages, and rerun backend tests.
- [ ] Upgrade parapharmacy API `mongoose`/`fast-uri` and transitive packages, then rerun API security tests.
- [ ] Upgrade frontend Vite/Rollup/esbuild and Vitest. Keep Vitest UI disabled in production and verify no UI server is started by CI or deployment.
- [ ] Upgrade scraper dependencies and confirm Puppeteer still uses the intended Chromium binary.
- [ ] Re-run `npm audit --json` for the workspace and each lockfile; document any accepted residual advisory with package, path, exploitability, and owner.
### Task 7: Verification gate before deployment
**Files:** No source changes unless verification exposes a regression.
- [ ] Run `git diff --check`.
- [ ] Run backend tests: `npm test --prefix apps/backend`.
- [ ] Run frontend tests: `npm test --prefix apps/frontend`.
- [ ] Run the parapharmacy security tests and all available package tests.
- [ ] Render Compose configuration with production-like variables and confirm no placeholder values remain: `docker compose config`.
- [ ] Build every production image with lockfile-enforced installs.
- [ ] Run a black-box smoke test proving public GET endpoints work, protected mutations return 401 without a key, and internal infrastructure is not host-published.
- [ ] Re-run secret scanning and `npm audit --audit-level=high`; block deployment on any critical/high issue without an explicit documented exception.
+142
View File
@@ -0,0 +1,142 @@
{
"annotations": { "list": [] },
"description": "JS errors and console errors captured by Grafana Faro. Data lives in Tempo as span events.",
"editable": true,
"graphTooltip": 1,
"id": null,
"links": [],
"liveNow": false,
"panels": [
{
"type": "stat",
"title": "Exceptions (5m)",
"datasource": { "type": "tempo", "uid": "P214B5B846CF3925F" },
"gridPos": { "h": 4, "w": 8, "x": 0, "y": 0 },
"fieldConfig": {
"defaults": {
"thresholds": { "steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 1 },
{ "color": "red", "value": 5 }
]}
}
},
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "background" },
"targets": [
{
"query": "{ service.name = \"farmafinder-frontend\" && span.event.name = \"exception\" }",
"refId": "A",
"limit": 1000,
"tableType": "traceql"
}
]
},
{
"type": "stat",
"title": "Console errors (5m)",
"datasource": { "type": "tempo", "uid": "P214B5B846CF3925F" },
"gridPos": { "h": 4, "w": 8, "x": 8, "y": 0 },
"fieldConfig": {
"defaults": {
"thresholds": { "steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 1 },
{ "color": "red", "value": 5 }
]}
}
},
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "background" },
"targets": [
{
"query": "{ service.name = \"farmafinder-frontend\" && span.event.name = \"console\" } | logfmt | level =~ \"error|critical\"",
"refId": "A",
"limit": 1000,
"tableType": "traceql"
}
]
},
{
"type": "stat",
"title": "Failed fetch/XHR (5m)",
"datasource": { "type": "tempo", "uid": "P214B5B846CF3925F" },
"gridPos": { "h": 4, "w": 8, "x": 16, "y": 0 },
"fieldConfig": {
"defaults": {
"thresholds": { "steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 1 },
{ "color": "red", "value": 5 }
]}
}
},
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "background" },
"targets": [
{
"query": "{ service.name = \"farmafinder-frontend\" && span.event.name = \"fetch\" } | logfmt | status_code >= 400",
"refId": "A",
"limit": 1000,
"tableType": "traceql"
}
]
},
{
"type": "table",
"title": "Recent exception traces",
"datasource": { "type": "tempo", "uid": "P214B5B846CF3925F" },
"gridPos": { "h": 10, "w": 24, "x": 0, "y": 4 },
"options": { "showHeader": true },
"targets": [
{
"query": "{ service.name = \"farmafinder-frontend\" && span.event.name = \"exception\" }",
"refId": "A",
"limit": 20,
"tableType": "traceql"
}
],
"transformations": [
{
"id": "organize",
"options": {
"excludeByName": { "traceID": false, "rootServiceName": true, "rootTraceName": false, "duration": true, "startTime": true },
"renameByName": { "traceID": "Trace ID", "rootTraceName": "Name", "duration": "Duration", "startTime": "Time" }
}
}
]
},
{
"type": "table",
"title": "Recent console errors",
"datasource": { "type": "tempo", "uid": "P214B5B846CF3925F" },
"gridPos": { "h": 10, "w": 24, "x": 0, "y": 14 },
"options": { "showHeader": true },
"targets": [
{
"query": "{ service.name = \"farmafinder-frontend\" && span.event.name = \"console\" } | logfmt | level =~ \"error|critical\"",
"refId": "A",
"limit": 20,
"tableType": "traceql"
}
],
"transformations": [
{
"id": "organize",
"options": {
"excludeByName": { "traceID": false, "rootServiceName": true, "rootTraceName": false, "duration": true, "startTime": true },
"renameByName": { "traceID": "Trace ID", "rootTraceName": "Name", "duration": "Duration", "startTime": "Time" }
}
}
]
}
],
"refresh": "30s",
"schemaVersion": 39,
"tags": ["faro", "errors", "farmafinder"],
"templating": { "list": [] },
"time": { "from": "now-1h", "to": "now" },
"timepicker": {},
"timezone": "browser",
"title": "FARO — JS Errors",
"uid": "faro-js-errors",
"version": 2,
"weekStart": ""
}
+139
View File
@@ -0,0 +1,139 @@
{
"annotations": { "list": [] },
"description": "Overview of Faro RUM signals. Data comes from browser traces via Tempo.",
"editable": true,
"graphTooltip": 1,
"id": null,
"links": [],
"liveNow": false,
"panels": [
{
"type": "stat",
"title": "Total spans (5m)",
"datasource": { "type": "tempo", "uid": "P214B5B846CF3925F" },
"gridPos": { "h": 4, "w": 6, "x": 0, "y": 0 },
"options": { "reduceOptions": { "calcs": ["lastNotNull"] } },
"targets": [
{
"query": "{ service.name = \"farmafinder-frontend\" }",
"refId": "A",
"limit": 1000,
"tableType": "traceql"
}
]
},
{
"type": "stat",
"title": "Exception spans (5m)",
"datasource": { "type": "tempo", "uid": "P214B5B846CF3925F" },
"gridPos": { "h": 4, "w": 6, "x": 6, "y": 0 },
"fieldConfig": {
"defaults": {
"thresholds": { "steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 1 },
{ "color": "red", "value": 5 }
]}
}
},
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "background" },
"targets": [
{
"query": "{ service.name = \"farmafinder-frontend\" && span.event.name = \"exception\" }",
"refId": "A",
"limit": 1000,
"tableType": "traceql"
}
]
},
{
"type": "stat",
"title": "Console error spans (5m)",
"datasource": { "type": "tempo", "uid": "P214B5B846CF3925F" },
"gridPos": { "h": 4, "w": 6, "x": 12, "y": 0 },
"fieldConfig": {
"defaults": {
"thresholds": { "steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 1 },
{ "color": "red", "value": 5 }
]}
}
},
"options": { "reduceOptions": { "calcs": ["lastNotNull"] }, "colorMode": "background" },
"targets": [
{
"query": "{ service.name = \"farmafinder-frontend\" && span.event.name = \"console\" } | logfmt | level =~ \"error|critical\"",
"refId": "A",
"limit": 1000,
"tableType": "traceql"
}
]
},
{
"type": "stat",
"title": "Navigation spans (5m)",
"datasource": { "type": "tempo", "uid": "P214B5B846CF3925F" },
"gridPos": { "h": 4, "w": 6, "x": 18, "y": 0 },
"options": { "reduceOptions": { "calcs": ["lastNotNull"] } },
"targets": [
{
"query": "{ service.name = \"farmafinder-frontend\" && span.event.name = \"navigation\" }",
"refId": "A",
"limit": 1000,
"tableType": "traceql"
}
]
},
{
"type": "table",
"title": "Recent traces from frontend",
"datasource": { "type": "tempo", "uid": "P214B5B846CF3925F" },
"gridPos": { "h": 10, "w": 24, "x": 0, "y": 4 },
"options": { "showHeader": true },
"targets": [
{
"query": "{ service.name = \"farmafinder-frontend\" }",
"refId": "A",
"limit": 20,
"tableType": "traceql"
}
],
"transformations": [
{
"id": "organize",
"options": {
"excludeByName": { "traceID": false, "rootServiceName": true, "rootTraceName": false, "duration": true, "startTime": true },
"renameByName": { "traceID": "Trace ID", "rootTraceName": "Name", "duration": "Duration", "startTime": "Time" }
}
}
]
},
{
"type": "logs",
"title": "Explore — Tempo traces",
"datasource": { "type": "tempo", "uid": "P214B5B846CF3925F" },
"gridPos": { "h": 10, "w": 24, "x": 0, "y": 14 },
"options": { "showTime": true, "showLabels": false },
"targets": [
{
"query": "{ service.name = \"farmafinder-frontend\" }",
"refId": "A",
"limit": 50,
"tableType": "traceql"
}
]
}
],
"refresh": "30s",
"schemaVersion": 39,
"tags": ["faro", "rum", "farmafinder"],
"templating": { "list": [] },
"time": { "from": "now-1h", "to": "now" },
"timepicker": {},
"timezone": "browser",
"title": "FARO — FarmaFinder RUM Overview",
"uid": "faro-overview",
"version": 2,
"weekStart": ""
}
+134
View File
@@ -0,0 +1,134 @@
{
"annotations": { "list": [] },
"description": "Core Web Vitals (LCP, CLS, FCP, TTFB, INP) from Grafana Faro. Vitals are captured as span events in Tempo.",
"editable": true,
"graphTooltip": 1,
"id": null,
"links": [],
"liveNow": false,
"panels": [
{
"type": "stat",
"title": "LCP p75 (ms)",
"datasource": { "type": "tempo", "uid": "P214B5B846CF3925F" },
"gridPos": { "h": 5, "w": 6, "x": 0, "y": 0 },
"fieldConfig": { "defaults": { "unit": "ms" } },
"options": { "reduceOptions": { "calcs": ["lastNotNull"] } },
"targets": [
{
"query": "{ service.name = \"farmafinder-frontend\" && span.event.name = \"web-vitals\" } | logfmt | name = \"LCP\"",
"refId": "A",
"limit": 1000,
"tableType": "traceql"
}
]
},
{
"type": "stat",
"title": "FCP p75 (ms)",
"datasource": { "type": "tempo", "uid": "P214B5B846CF3925F" },
"gridPos": { "h": 5, "w": 6, "x": 6, "y": 0 },
"fieldConfig": { "defaults": { "unit": "ms" } },
"options": { "reduceOptions": { "calcs": ["lastNotNull"] } },
"targets": [
{
"query": "{ service.name = \"farmafinder-frontend\" && span.event.name = \"web-vitals\" } | logfmt | name = \"FCP\"",
"refId": "A",
"limit": 1000,
"tableType": "traceql"
}
]
},
{
"type": "stat",
"title": "CLS p75",
"datasource": { "type": "tempo", "uid": "P214B5B846CF3925F" },
"gridPos": { "h": 5, "w": 6, "x": 12, "y": 0 },
"fieldConfig": { "defaults": { "unit": "none", "decimals": 3 } },
"options": { "reduceOptions": { "calcs": ["lastNotNull"] } },
"targets": [
{
"query": "{ service.name = \"farmafinder-frontend\" && span.event.name = \"web-vitals\" } | logfmt | name = \"CLS\"",
"refId": "A",
"limit": 1000,
"tableType": "traceql"
}
]
},
{
"type": "stat",
"title": "TTFB p75 (ms)",
"datasource": { "type": "tempo", "uid": "P214B5B846CF3925F" },
"gridPos": { "h": 5, "w": 6, "x": 18, "y": 0 },
"fieldConfig": { "defaults": { "unit": "ms" } },
"options": { "reduceOptions": { "calcs": ["lastNotNull"] } },
"targets": [
{
"query": "{ service.name = \"farmafinder-frontend\" && span.event.name = \"web-vitals\" } | logfmt | name = \"TTFB\"",
"refId": "A",
"limit": 1000,
"tableType": "traceql"
}
]
},
{
"type": "table",
"title": "Recent web-vitals events",
"datasource": { "type": "tempo", "uid": "P214B5B846CF3925F" },
"gridPos": { "h": 12, "w": 24, "x": 0, "y": 5 },
"options": { "showHeader": true },
"targets": [
{
"query": "{ service.name = \"farmafinder-frontend\" && span.event.name = \"web-vitals\" }",
"refId": "A",
"limit": 30,
"tableType": "traceql"
}
],
"transformations": [
{
"id": "organize",
"options": {
"excludeByName": { "traceID": false, "rootServiceName": true, "rootTraceName": false, "duration": true, "startTime": true },
"renameByName": { "traceID": "Trace ID", "rootTraceName": "Name", "duration": "Duration", "startTime": "Time" }
}
}
]
},
{
"type": "table",
"title": "Recent navigation events",
"datasource": { "type": "tempo", "uid": "P214B5B846CF3925F" },
"gridPos": { "h": 10, "w": 24, "x": 0, "y": 17 },
"options": { "showHeader": true },
"targets": [
{
"query": "{ service.name = \"farmafinder-frontend\" && span.event.name = \"navigation\" }",
"refId": "A",
"limit": 20,
"tableType": "traceql"
}
],
"transformations": [
{
"id": "organize",
"options": {
"excludeByName": { "traceID": false, "rootServiceName": true, "rootTraceName": false, "duration": true, "startTime": true },
"renameByName": { "traceID": "Trace ID", "rootTraceName": "Name", "duration": "Duration", "startTime": "Time" }
}
}
]
}
],
"refresh": "30s",
"schemaVersion": 39,
"tags": ["faro", "web-vitals", "farmafinder"],
"templating": { "list": [] },
"time": { "from": "now-3h", "to": "now" },
"timepicker": {},
"timezone": "browser",
"title": "FARO — Web Vitals",
"uid": "faro-web-vitals",
"version": 2,
"weekStart": ""
}
+12 -164
View File
@@ -54,11 +54,12 @@
"express-rate-limit": "^8.5.2", "express-rate-limit": "^8.5.2",
"express-session": "^1.17.3", "express-session": "^1.17.3",
"multer": "^2.2.0", "multer": "^2.2.0",
"nodemailer": "^6.10.1",
"pg": "^8.13.0", "pg": "^8.13.0",
"pino": "^9.4.0", "pino": "^9.4.0",
"pino-http": "^10.3.0", "pino-http": "^10.3.0",
"redis": "^4.6.0", "redis": "^4.6.0",
"sqlite3": "^5.1.6", "sqlite3": "^5.1.7",
"tesseract.js": "^7.0.0", "tesseract.js": "^7.0.0",
"web-push": "^3.6.7" "web-push": "^3.6.7"
}, },
@@ -127,18 +128,17 @@
"devDependencies": { "devDependencies": {
"@testing-library/jest-dom": "^6.4.0", "@testing-library/jest-dom": "^6.4.0",
"@testing-library/react": "^14.2.0", "@testing-library/react": "^14.2.0",
"@vitejs/plugin-react": "^4.2.1", "@vitejs/plugin-react": "^4.7.0",
"jsdom": "^24.0.0", "jsdom": "^24.0.0",
"vite": "^5.0.8", "vite": "^5.0.8",
"vite-plugin-pwa": "^1.3.0", "vite-plugin-pwa": "^1.3.0",
"vitest": "^1.6.0" "vitest": "^1.6.1"
} }
}, },
"apps/frontend-mobile": { "apps/frontend-mobile": {
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@expo/vector-icons": "^15.0.2", "@expo/vector-icons": "^15.0.2",
"@grafana/faro-react-native": "^1.3.0",
"@react-native-async-storage/async-storage": "2.2.0", "@react-native-async-storage/async-storage": "2.2.0",
"@tanstack/react-query": "^5.101.2", "@tanstack/react-query": "^5.101.2",
"axios": "^1.18.1", "axios": "^1.18.1",
@@ -403,158 +403,6 @@
"react-native": "*" "react-native": "*"
} }
}, },
"apps/frontend-mobile/node_modules/@grafana/faro-core": {
"version": "2.8.2",
"resolved": "https://registry.npmjs.org/@grafana/faro-core/-/faro-core-2.8.2.tgz",
"integrity": "sha512-63C6+N/P9/ySUMaGut8yVb1DkPuHS5I/lfRm97+aDXFj3+8pxRT/QzyXNe0r6KMU5O95wC9FLVGxG9ZvkwEhJQ==",
"dependencies": {
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/otlp-transformer": "^0.219.0"
}
},
"apps/frontend-mobile/node_modules/@grafana/faro-react-native": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/@grafana/faro-react-native/-/faro-react-native-1.3.0.tgz",
"integrity": "sha512-KeohD3S2xmiukL5YW3mOGDqvFiwpD9IzJ2EtZ50ktEsnA+6EMt2AtJ8RzJfAamGTYqemBpDiw51rqB4ID+C2xg==",
"license": "Apache-2.0",
"dependencies": {
"@grafana/faro-core": "^2.7.0",
"@react-native-async-storage/async-storage": "^1.21.0",
"react-native-device-info": "^11.1.0"
},
"peerDependencies": {
"@react-navigation/native": ">=6.0.0",
"react": ">=18.0.0",
"react-native": ">=0.70.0",
"react-native-mmkv": ">=2.0.0"
},
"peerDependenciesMeta": {
"@react-navigation/native": {
"optional": true
},
"react-native-mmkv": {
"optional": true
}
}
},
"apps/frontend-mobile/node_modules/@grafana/faro-react-native/node_modules/@react-native-async-storage/async-storage": {
"version": "1.24.0",
"resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-1.24.0.tgz",
"integrity": "sha512-W4/vbwUOYOjco0x3toB8QCr7EjIP6nE9G7o8PMguvvjYT5Awg09lyV4enACRx4s++PPulBiBSjL0KTFx2u0Z/g==",
"dependencies": {
"merge-options": "^3.0.4"
},
"peerDependencies": {
"react-native": "^0.0.0-0 || >=0.60 <1.0"
}
},
"apps/frontend-mobile/node_modules/@opentelemetry/api-logs": {
"version": "0.219.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.219.0.tgz",
"integrity": "sha512-FFx7YnaYJlIjqWW/AG/yAZ0L/NEY724PipXXXQLdtZPbLwBGbUMTGL1i/esI56TWfTUXxhLfpgrnWJCG8aUJyg==",
"dependencies": {
"@opentelemetry/api": "^1.3.0"
},
"engines": {
"node": ">=8.0.0"
}
},
"apps/frontend-mobile/node_modules/@opentelemetry/core": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.8.0.tgz",
"integrity": "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww==",
"dependencies": {
"@opentelemetry/semantic-conventions": "^1.29.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.0.0 <1.10.0"
}
},
"apps/frontend-mobile/node_modules/@opentelemetry/otlp-transformer": {
"version": "0.219.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.219.0.tgz",
"integrity": "sha512-aaYKAyXhw9VchKZVGOopD3Gw/kPsyrX2c6IQ0AW32mTjqmZOh5Y6Gf5OYqTNqVktAeBjmFinhyFaCwW6GYK9YQ==",
"dependencies": {
"@opentelemetry/api-logs": "0.219.0",
"@opentelemetry/core": "2.8.0",
"@opentelemetry/resources": "2.8.0",
"@opentelemetry/sdk-logs": "0.219.0",
"@opentelemetry/sdk-metrics": "2.8.0",
"@opentelemetry/sdk-trace-base": "2.8.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": "^1.3.0"
}
},
"apps/frontend-mobile/node_modules/@opentelemetry/resources": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.8.0.tgz",
"integrity": "sha512-qmXQ27ilDbUK/vGMqwL8D4/rhn76C+sherM4wTbjlfknR8Nvfc/hCxjRJPhkzZzUsPiNg16SA31NxMabwttRjg==",
"dependencies": {
"@opentelemetry/core": "2.8.0",
"@opentelemetry/semantic-conventions": "^1.29.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.3.0 <1.10.0"
}
},
"apps/frontend-mobile/node_modules/@opentelemetry/sdk-logs": {
"version": "0.219.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.219.0.tgz",
"integrity": "sha512-s6lTKRakaPClvKoWHRChxnXjDMkM/TQ30ff78jN6EBGf7MI7VzANE5PU3f4z9qDUudWjvZjOLHG0rBnBKYvoXA==",
"dependencies": {
"@opentelemetry/api-logs": "0.219.0",
"@opentelemetry/core": "2.8.0",
"@opentelemetry/resources": "2.8.0",
"@opentelemetry/semantic-conventions": "^1.29.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.4.0 <1.10.0"
}
},
"apps/frontend-mobile/node_modules/@opentelemetry/sdk-metrics": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.8.0.tgz",
"integrity": "sha512-UDBGaj6W0Rgy5rTTaoxs8gVGF/aGkAKyjurJv7se6wjRxJu7FoquTLT/vt54DZfo4crbprYfhX/SOK9+BPw1qg==",
"dependencies": {
"@opentelemetry/core": "2.8.0",
"@opentelemetry/resources": "2.8.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.9.0 <1.10.0"
}
},
"apps/frontend-mobile/node_modules/@opentelemetry/sdk-trace-base": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.8.0.tgz",
"integrity": "sha512-mhU4jp+vW0mGbFRd+GeXHvmfA4aDqWjBjLC3pE5XMpLs0IE2ryYb019Ts2AQrOq67gaTF25D91+fgvEHDZEnuQ==",
"dependencies": {
"@opentelemetry/core": "2.8.0",
"@opentelemetry/resources": "2.8.0",
"@opentelemetry/semantic-conventions": "^1.29.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.3.0 <1.10.0"
}
},
"apps/frontend-mobile/node_modules/@react-native-async-storage/async-storage": { "apps/frontend-mobile/node_modules/@react-native-async-storage/async-storage": {
"version": "2.2.0", "version": "2.2.0",
"resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-2.2.0.tgz", "resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-2.2.0.tgz",
@@ -1277,14 +1125,6 @@
} }
} }
}, },
"apps/frontend-mobile/node_modules/react-native-device-info": {
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/react-native-device-info/-/react-native-device-info-11.1.0.tgz",
"integrity": "sha512-hzXJSObJdezEz0hF7MAJ3tGeoesuQWenXXt9mrQR9Mjb8kXpZ09rqSsZ/quNpJdZpQ3rYiFa3/0GFG5KNn9PBg==",
"peerDependencies": {
"react-native": "*"
}
},
"apps/frontend-mobile/node_modules/react-native-drawer-layout": { "apps/frontend-mobile/node_modules/react-native-drawer-layout": {
"version": "4.2.7", "version": "4.2.7",
"resolved": "https://registry.npmjs.org/react-native-drawer-layout/-/react-native-drawer-layout-4.2.7.tgz", "resolved": "https://registry.npmjs.org/react-native-drawer-layout/-/react-native-drawer-layout-4.2.7.tgz",
@@ -22421,6 +22261,14 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/nodemailer": {
"version": "6.10.1",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.10.1.tgz",
"integrity": "sha512-Z+iLaBGVaSjbIzQ4pX6XV41HrooLsQ10ZWPUehGmuantvzWoDVBnmsdUcOIDM1t+yPor5pDhVlDESgOMEGxhHA==",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/nopt": { "node_modules/nopt": {
"version": "5.0.0", "version": "5.0.0",
"resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz",
+191
View File
@@ -0,0 +1,191 @@
#!/usr/bin/env bash
# deploy-env.sh — Fetch Gitea Actions variables and write .env files.
# Called by the CI/CD deploy step on the self-hosted runner.
#
# Required env vars (injected by Gitea Actions):
# GITEA_TOKEN — API token with repo read access
# GITEA_OWNER — repo owner (e.g. Ichitux)
# GITEA_REPO — repo name (e.g. FarmaFinder)
#
# Usage:
# GITEA_TOKEN=xxx GITEA_OWNER=Ichitux GITEA_REPO=FarmaFinder ./scripts/deploy-env.sh
set -euo pipefail
GITEA_URL="${GITEA_URL:-https://git.hacecalor.net}"
WORK_DIR="${WORK_DIR:-.}"
# ---------------------------------------------------------------------------
# Helper: fetch all repo variables from Gitea API (handles pagination)
# Returns JSON array on stdout
# ---------------------------------------------------------------------------
fetch_variables() {
local page=1 limit=50 all='[]'
while true; do
local resp
resp=$(curl -sf \
-H "Authorization: token ${GITEA_TOKEN}" \
"${GITEA_URL}/api/v1/repos/${GITEA_OWNER}/${GITEA_REPO}/actions/variables?limit=${limit}&page=${page}")
local count
count=$(echo "$resp" | python3 -c "import sys,json; print(len(json.load(sys.stdin)))")
all=$(python3 -c "
import sys, json
a = json.loads('''${all}''')
b = json.loads(sys.stdin.read())
print(json.dumps(a + b))
" <<< "$resp")
if [ "$count" -lt "$limit" ]; then
break
fi
page=$((page + 1))
done
echo "$all"
}
# ---------------------------------------------------------------------------
# Helper: write a .env file from a JSON object of key-value pairs.
# Keys are sorted alphabetically. Comments from .env.example are preserved.
# ---------------------------------------------------------------------------
write_env() {
local env_path="$1"
local json_obj="$2"
local example_path="${env_path}.example"
# If no example file exists, write a plain KEY=VALUE file
if [ ! -f "$example_path" ]; then
python3 -c "
import json, sys
obj = json.loads('''${json_obj}''')
for k in sorted(obj):
print(f'{k}={obj[k]}')
" > "$env_path"
echo "[env] Wrote $env_path (${#json_obj} bytes, no .example template)"
return
fi
# If .example exists, produce a file that preserves comments/order
# and fills in values from the JSON
python3 -c "
import json, sys, re
example_path = '''${example_path}'''
env_path = '''${env_path}'''
values = json.loads('''${json_obj}''')
with open(example_path) as f:
lines = f.readlines()
output = []
written_keys = set()
for line in lines:
stripped = line.rstrip('\n')
# Pass through comments and blank lines
if stripped.startswith('#') or stripped.strip() == '':
output.append(stripped)
continue
# Parse KEY=VALUE lines
m = re.match(r'^([A-Za-z_][A-Za-z0-9_]*)=(.*)', stripped)
if m:
key, _ = m.group(1), m.group(2)
if key in values:
output.append(f'{key}={values[key]}')
written_keys.add(key)
else:
output.append(stripped) # keep example default
else:
output.append(stripped)
# Append any keys from values that weren't in .example
for k in sorted(values):
if k not in written_keys:
output.append(f'{k}={values[k]}')
with open(env_path, 'w') as f:
f.write('\n'.join(output) + '\n')
print(f'[env] Wrote {env_path} ({len(values)} vars)')
" 2>&1
}
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
echo "=== deploy-env.sh ==="
echo "Fetching variables from Gitea: ${GITEA_OWNER}/${GITEA_REPO}"
VARS_JSON=$(fetch_variables)
VAR_COUNT=$(echo "$VARS_JSON" | python3 -c "import sys,json; print(len(json.load(sys.stdin)))")
echo "Found ${VAR_COUNT} variables"
# Group variables by prefix
# ROOT_* → root .env (strip prefix)
# BACKEND_* → apps/backend/.env (strip prefix)
# FRONTEND_* → apps/frontend/.env (strip prefix)
# PARAPHARMACY_* → apps/parapharmacy-api/.env (strip prefix)
# No prefix → root .env (backward compat with original vars)
ROOT_VARS=$(python3 -c "
import json, sys
vars = json.loads(sys.stdin.read())
# Keys that are CI-only and should NOT appear in application .env files
skip = {'GITEA_TOKEN'}
result = {}
for v in vars:
name = v['name']
value = v['data']
if name in skip:
continue
if name.startswith('ROOT_'):
result[name[5:]] = value # strip ROOT_ prefix
elif not any(name.startswith(p) for p in ['BACKEND_', 'FRONTEND_', 'PARAPHARMACY_']):
result[name] = value # unprefixed → root
print(json.dumps(result))
" <<< "$VARS_JSON")
BACKEND_VARS=$(python3 -c "
import json, sys
vars = json.loads(sys.stdin.read())
result = {}
for v in vars:
name = v['name']
if name.startswith('BACKEND_'):
result[name[8:]] = value = v['data']
print(json.dumps(result))
" <<< "$VARS_JSON")
FRONTEND_VARS=$(python3 -c "
import json, sys
vars = json.loads(sys.stdin.read())
result = {}
for v in vars:
name = v['name']
if name.startswith('FRONTEND_'):
result[name[9:]] = v['data']
print(json.dumps(result))
" <<< "$VARS_JSON")
PARAPHARMACY_VARS=$(python3 -c "
import json, sys
vars = json.loads(sys.stdin.read())
result = {}
for v in vars:
name = v['name']
if name.startswith('PARAPHARMACY_'):
result[name[13:]] = v['data']
print(json.dumps(result))
" <<< "$VARS_JSON")
# Write each .env file
echo ""
write_env "${WORK_DIR}/.env" "$ROOT_VARS"
write_env "${WORK_DIR}/apps/backend/.env" "$BACKEND_VARS"
write_env "${WORK_DIR}/apps/frontend/.env" "$FRONTEND_VARS"
write_env "${WORK_DIR}/apps/parapharmacy-api/.env" "$PARAPHARMACY_VARS"
echo ""
echo "=== All .env files written ==="
@@ -0,0 +1,198 @@
import fs from 'fs';
import path from 'path';
const outPath = path.resolve('Patentes/INTERNATIONAL_PRIOR_ART_SEARCH_FARMAFINDER_EN.pdf');
const sections = [
{ type: 'title', text: 'International Prior Art Search' },
{ type: 'subtitle', text: 'Project: FarmaFinder' },
{ type: 'spacer', lines: 1 },
{ type: 'paragraph', text: 'This document summarizes the international prior-art position for the technical concept described in the FarmaFinder patent materials.' },
{ type: 'heading', text: 'Technical scope' },
{ type: 'paragraph', text: 'The invention concerns a computer-implemented system and method for identifying a therapeutic need from the scan of an individual health card or equivalent patient-related identifier, associating that result with an authenticated user, comparing the inferred need with geolocated pharmacy inventory, and generating supply-related actions when the required product is not available.' },
{ type: 'heading', text: 'Main categories of prior art identified' },
{ type: 'number', text: 'Medicine search platforms that allow users to locate medicines by name or active ingredient.' },
{ type: 'number', text: 'Pharmacy locator systems that display nearby pharmacies and, in some cases, opening hours, distance, or approximate availability.' },
{ type: 'number', text: 'Inventory management systems used by pharmacies, distributors, or health-related retail networks.' },
{ type: 'number', text: 'Digital health applications that store user profiles, location data, reminders, or treatment-related information.' },
{ type: 'number', text: 'Reservation, referral, or notification workflows that allow a user or a pharmacy to react when a product is out of stock.' },
{ type: 'heading', text: 'Distinguishing technical features' },
{ type: 'bullet', text: 'secure capture of a health-card identifier or equivalent patient-related credential;' },
{ type: 'bullet', text: 'verification of the captured data and association with an authenticated user profile;' },
{ type: 'bullet', text: 'automatic derivation of a medicine reference or therapeutic need;' },
{ type: 'bullet', text: 'real-time comparison against pharmacy stock data from geolocated pharmacies;' },
{ type: 'bullet', text: 'ranking of pharmacies according to availability, distance, and opening status;' },
{ type: 'bullet', text: 'automatic generation of reserve, notification, order, or referral actions when stock is insufficient;' },
{ type: 'bullet', text: 'technical traceability of the event across the processing chain.' },
{ type: 'heading', text: 'Assessment' },
{ type: 'paragraph', text: 'The reviewed prior-art categories appear to disclose individual parts of the technical problem, but not the complete integrated architecture described above in a single system. In particular, the combination of patient-context capture through a health identifier, automated therapeutic inference, geolocated stock comparison, and machine-generated supply actions does not appear to be disclosed as a unified technical chain in the documents reviewed at the project level.' },
{ type: 'heading', text: 'Suggested search focus for the European search phase' },
{ type: 'bullet', text: 'patient identifier scanning;' },
{ type: 'bullet', text: 'health-card reading or optical capture of patient credentials;' },
{ type: 'bullet', text: 'medicine search and pharmacy availability systems;' },
{ type: 'bullet', text: 'pharmacy reservation and referral workflows;' },
{ type: 'bullet', text: 'location-based ranking of pharmacies;' },
{ type: 'bullet', text: 'automated inventory-driven notification or order generation;' },
{ type: 'bullet', text: 'health-related digital assistants that combine user context with stock availability.' },
{ type: 'heading', text: 'Working conclusion' },
{ type: 'paragraph', text: 'On the basis of the current project documentation, the invention is presented as a computer-implemented technical solution that integrates identification, verification, geolocation, inventory contrast, and automated supply actions in one workflow. No single reference has been identified at the project level that clearly discloses the full combination of these features.' },
{ type: 'heading', text: 'Date' },
{ type: 'paragraph', text: '13 August 2026' },
];
const PAGE_W = 595.28;
const PAGE_H = 841.89;
const MARGIN_L = 56;
const MARGIN_R = 56;
const MARGIN_T = 54;
const MARGIN_B = 50;
const CONTENT_W = PAGE_W - MARGIN_L - MARGIN_R;
function esc(s) {
return String(s).replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)');
}
function latin1(s) {
return Buffer.from(s, 'latin1').toString('latin1');
}
function wrapText(text, size) {
const avg = size * 0.48;
const max = Math.max(25, Math.floor(CONTENT_W / avg));
const words = String(text).split(/\s+/);
const lines = [];
let line = '';
for (const word of words) {
const candidate = line ? `${line} ${word}` : word;
if (candidate.length > max && line) {
lines.push(line);
line = word;
} else {
line = candidate;
}
}
if (line) lines.push(line);
return lines;
}
function buildPdf() {
const objects = [''];
const add = (body) => {
objects.push(body);
return objects.length - 1;
};
const fontReg = add('<< /Type /Font /Subtype /Type1 /BaseFont /Times-Roman /Encoding /WinAnsiEncoding >>');
const fontBold = add('<< /Type /Font /Subtype /Type1 /BaseFont /Times-Bold /Encoding /WinAnsiEncoding >>');
const fontIt = add('<< /Type /Font /Subtype /Type1 /BaseFont /Times-Italic /Encoding /WinAnsiEncoding >>');
const pagesTree = add('');
const contentNums = [];
const pageNums = [];
const pageContent = (pageLines, pageNo) => {
const ops = [];
let y = PAGE_H - MARGIN_T;
let numberCounter = 1;
const write = (txt, x, size, font = 'F1') => {
ops.push(`BT /${font} ${size} Tf ${x} ${y} Td (${esc(latin1(txt))}) Tj ET`);
y -= size * 1.25;
};
const ensure = (needed) => y - needed > MARGIN_B;
for (const item of pageLines) {
if (item.type === 'title') {
const lines = wrapText(item.text, 18);
for (const line of lines) write(line, MARGIN_L + 82, 18, 'F2');
y -= 4;
continue;
}
if (item.type === 'subtitle') {
const lines = wrapText(item.text, 12);
for (const line of lines) write(line, MARGIN_L + 100, 12, 'F3');
y -= 8;
continue;
}
if (item.type === 'spacer') {
y -= (item.lines || 1) * 12;
continue;
}
if (item.type === 'heading') {
if (!ensure(24)) break;
write(item.text, MARGIN_L, 13, 'F2');
y -= 4;
ops.push(`0.3 w ${MARGIN_L} ${y} m ${PAGE_W - MARGIN_R} ${y} l S`);
y -= 8;
continue;
}
if (item.type === 'paragraph') {
const lines = wrapText(item.text, 11.5);
if (!ensure(lines.length * 14 + 6)) break;
for (const line of lines) write(line, MARGIN_L, 11.5, 'F1');
y -= 2;
continue;
}
if (item.type === 'bullet') {
const lines = wrapText(item.text, 11.2);
if (!ensure(lines.length * 14 + 6)) break;
let first = true;
for (const line of lines) {
write((first ? '- ' : ' ') + line, MARGIN_L + 8, 11.2, 'F1');
first = false;
}
continue;
}
if (item.type === 'number') {
const lines = wrapText(item.text, 11.2);
if (!ensure(lines.length * 14 + 6)) break;
let first = true;
for (const line of lines) {
write((first ? `${numberCounter}. ` : ' ') + line, MARGIN_L + 8, 11.2, 'F1');
first = false;
}
numberCounter += 1;
continue;
}
}
ops.push(`BT /F1 9 Tf ${PAGE_W - 100} ${24} Td (Page ${pageNo}) Tj ET`);
return ops.join('\n');
};
const page1 = sections.slice(0, 12);
const page2 = sections.slice(12);
const pages = [page1, page2];
for (let i = 0; i < pages.length; i += 1) {
const content = pageContent(pages[i], i + 1);
contentNums.push(add(`<< /Length ${Buffer.byteLength(content, 'latin1')} >>\nstream\n${content}\nendstream`));
}
for (let i = 0; i < pages.length; i += 1) {
pageNums.push(add(`<< /Type /Page /Parent ${pagesTree} 0 R /MediaBox [0 0 ${PAGE_W} ${PAGE_H}] /Resources << /Font << /F1 ${fontReg} 0 R /F2 ${fontBold} 0 R /F3 ${fontIt} 0 R >> >> /Contents ${contentNums[i]} 0 R >>`));
}
objects[pagesTree] = `<< /Type /Pages /Kids [${pageNums.map(n => `${n} 0 R`).join(' ')}] /Count ${pageNums.length} >>`;
const catalog = add(`<< /Type /Catalog /Pages ${pagesTree} 0 R >>`);
let pdf = '%PDF-1.4\n';
const offsets = [0];
for (let i = 1; i < objects.length; i += 1) {
offsets[i] = Buffer.byteLength(pdf, 'latin1');
pdf += `${i} 0 obj\n${objects[i]}\nendobj\n`;
}
const xref = Buffer.byteLength(pdf, 'latin1');
pdf += `xref\n0 ${objects.length}\n`;
pdf += '0000000000 65535 f \n';
for (let i = 1; i < objects.length; i += 1) {
pdf += `${String(offsets[i]).padStart(10, '0')} 00000 n \n`;
}
pdf += `trailer << /Size ${objects.length} /Root ${catalog} 0 R >>\nstartxref\n${xref}\n%%EOF\n`;
fs.writeFileSync(outPath, Buffer.from(pdf, 'latin1'));
}
buildPdf();
console.log(`Wrote ${outPath}`);
+150
View File
@@ -0,0 +1,150 @@
import fs from 'fs';
import os from 'os';
import path from 'path';
import { execFileSync } from 'child_process';
const root = process.cwd();
const sourcePdf = path.join(root, 'Patentes', 'SOLICITUD_PATENTE_OEPM_FARMAFINDER.pdf');
const outDir = path.join(root, 'Patentes');
function ensureExists(file) {
if (!fs.existsSync(file)) {
throw new Error(`Missing file: ${file}`);
}
}
function run(cmd, args) {
execFileSync(cmd, args, { stdio: 'inherit' });
}
function mergePages(output, pages) {
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'farmafinder-clean-'));
run('pdfseparate', [sourcePdf, path.join(tmp, 'page-%d.pdf')]);
const inputs = pages.map((n) => path.join(tmp, `page-${n}.pdf`));
run('pdfunite', [...inputs, output]);
}
function esc(s) {
return String(s).replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)');
}
function latin1(s) {
return Buffer.from(s, 'latin1').toString('latin1');
}
function wrapText(text, widthChars) {
const words = String(text).split(/\s+/);
const lines = [];
let line = '';
for (const word of words) {
const candidate = line ? `${line} ${word}` : word;
if (candidate.length > widthChars && line) {
lines.push(line);
line = word;
} else {
line = candidate;
}
}
if (line) lines.push(line);
return lines;
}
function writeDrawingsPdf(output) {
const PAGE_W = 595.28;
const PAGE_H = 841.89;
const MARGIN_L = 56;
const MARGIN_R = 56;
const MARGIN_T = 56;
const MARGIN_B = 54;
const CONTENT_W = PAGE_W - MARGIN_L - MARGIN_R;
const lines = [
{ type: 'title', text: 'Dibujos' },
{ type: 'subtitle', text: 'Bloque limpio para la presentación de la solicitud' },
{ type: 'spacer', lines: 1 },
{ type: 'heading', text: 'Breve descripción de los dibujos' },
{ type: 'paragraph', text: 'Figura 1. Diagrama de bloques general del sistema.' },
{ type: 'paragraph', text: 'Figura 2. Flujo de captura y validación del escaneo de la TSI.' },
{ type: 'paragraph', text: 'Figura 3. Flujo de contraste entre necesidad terapéutica y stock de farmacias.' },
{ type: 'paragraph', text: 'Figura 4. Flujo de acción cuando no existe stock disponible.' },
{ type: 'spacer', lines: 1 },
{ type: 'heading', text: 'Nota' },
{ type: 'paragraph', text: 'El documento fuente contiene referencias esquemáticas a las figuras, pero no incluye láminas técnicas dibujadas de forma separada. Si la Oficina requiere placas gráficas, habrá que añadirlas antes de la presentación definitiva.' },
];
const objects = [''];
const add = (body) => {
objects.push(body);
return objects.length - 1;
};
const fontReg = add('<< /Type /Font /Subtype /Type1 /BaseFont /Times-Roman /Encoding /WinAnsiEncoding >>');
const fontBold = add('<< /Type /Font /Subtype /Type1 /BaseFont /Times-Bold /Encoding /WinAnsiEncoding >>');
const fontIt = add('<< /Type /Font /Subtype /Type1 /BaseFont /Times-Italic /Encoding /WinAnsiEncoding >>');
const pagesTree = add('');
const ops = [];
let y = PAGE_H - MARGIN_T;
const write = (txt, x, size, font = 'F1') => {
ops.push(`BT /${font} ${size} Tf ${x} ${y} Td (${esc(latin1(txt))}) Tj ET`);
y -= size * 1.25;
};
const ensure = (needed) => y - needed > MARGIN_B;
for (const item of lines) {
if (item.type === 'title') {
for (const line of wrapText(item.text, 26)) write(line, MARGIN_L + 175, 18, 'F2');
continue;
}
if (item.type === 'subtitle') {
for (const line of wrapText(item.text, 40)) write(line, MARGIN_L + 45, 11.5, 'F3');
y -= 6;
continue;
}
if (item.type === 'spacer') {
y -= (item.lines || 1) * 12;
continue;
}
if (item.type === 'heading') {
if (!ensure(20)) break;
write(item.text, MARGIN_L, 13, 'F2');
y -= 2;
ops.push(`0.3 w ${MARGIN_L} ${y} m ${PAGE_W - MARGIN_R} ${y} l S`);
y -= 8;
continue;
}
if (item.type === 'paragraph') {
const wrapped = wrapText(item.text, 80);
if (!ensure(wrapped.length * 14 + 6)) break;
for (const line of wrapped) write(line, MARGIN_L, 11.5, 'F1');
y -= 2;
}
}
ops.push(`BT /F1 9 Tf ${PAGE_W - 100} ${24} Td (Page 1) Tj ET`);
const content = ops.join('\n');
const contentObj = add(`<< /Length ${Buffer.byteLength(content, 'latin1')} >>\nstream\n${content}\nendstream`);
const pageObj = add(`<< /Type /Page /Parent ${pagesTree} 0 R /MediaBox [0 0 ${PAGE_W} ${PAGE_H}] /Resources << /Font << /F1 ${fontReg} 0 R /F2 ${fontBold} 0 R /F3 ${fontIt} 0 R >> >> /Contents ${contentObj} 0 R >>`);
objects[pagesTree] = `<< /Type /Pages /Kids [${pageObj} 0 R] /Count 1 >>`;
const catalog = add(`<< /Type /Catalog /Pages ${pagesTree} 0 R >>`);
let pdf = '%PDF-1.4\n';
const offsets = [0];
for (let i = 1; i < objects.length; i += 1) {
offsets[i] = Buffer.byteLength(pdf, 'latin1');
pdf += `${i} 0 obj\n${objects[i]}\nendobj\n`;
}
const xref = Buffer.byteLength(pdf, 'latin1');
pdf += `xref\n0 ${objects.length}\n`;
pdf += '0000000000 65535 f \n';
for (let i = 1; i < objects.length; i += 1) {
pdf += `${String(offsets[i]).padStart(10, '0')} 00000 n \n`;
}
pdf += `trailer << /Size ${objects.length} /Root ${catalog} 0 R >>\nstartxref\n${xref}\n%%EOF\n`;
fs.writeFileSync(output, Buffer.from(pdf, 'latin1'));
}
ensureExists(sourcePdf);
mergePages(path.join(outDir, 'SOLICITUD_PATENTE_OEPM_FARMAFINDER_descripcion_limpia.pdf'), [2, 3, 4]);
mergePages(path.join(outDir, 'SOLICITUD_PATENTE_OEPM_FARMAFINDER_reivindicaciones_limpia.pdf'), [5]);
mergePages(path.join(outDir, 'SOLICITUD_PATENTE_OEPM_FARMAFINDER_resumen_limpia.pdf'), [6]);
writeDrawingsPdf(path.join(outDir, 'SOLICITUD_PATENTE_OEPM_FARMAFINDER_dibujos_limpia.pdf'));
console.log('Clean section PDFs generated.');