Compare commits

..
34 Commits
Author SHA1 Message Date
Lizandro GuarnizoandClaude Sonnet 4.6 23db497730 fix: include id and phone in professionals.users for findById
The findById endpoint only selected {name, picture} from professionals.users,
so when a patient viewed a service detail the professional's id and phone
were missing. This caused:
- The call button to be hidden (phone was null)
- The chat route to be built with an empty professional ID ('/chat/')
  making the Firestore stream unable to find the chat → spinner forever

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-22 10:16:53 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 b4857dbc75 fix: notify professional via FCM when service is created
Backend create() now looks up the professional's fcm_token and sends a
push notification after the service is persisted. This replaces the
unreliable client-side notification in the Flutter app (which couldn't
use the server FCM key).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-22 09:33:38 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 d7a0769b46 feat: filter professionals by city and proximity
- findAllActive now accepts city, lat, lng params
- City filter: case-insensitive contains on users.city
- Proximity sort: Haversine within city before services/score ranking
- Controller exposes ?city=&lat=&lng= query params

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-21 20:35:03 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 00a6f637f6 feat: rank professionals by completed services + score, search by name/profession/city
- GET /professionals now accepts ?search= instead of ?city=/page/limit
- Filters each word across profession, user name, and user city (AND of OR)
- Counts completed services per professional via Prisma _count filter
- Sorts: most completed services first, then highest average_score
- Always returns top 7 results

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-12 20:18:14 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 74be21eb4e fix: use 'as any[]' for notIn array to avoid readonly tuple type error
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-12 20:02:10 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 7d481b3451 Exclude self_booked/denied/cancelled from professional services list
findByProfessional had no status filter, so blocked slots (self_booked)
and historical cancelled/denied services appeared in the active view.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-12 19:54:08 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 c720911172 add configurable appointment slot duration per professional
DB: slot_duration_minutes INT DEFAULT 30 on professionals table
Backend DTO: slot_duration_minutes (optional int, 5-480 min) in UpdateProfessionalDto

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-12 19:24:49 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 afbf4d537e show gender on admin professional detail page
Add gender: true to users select in findById() (both lookup paths)
and render it in the personal data card in the admin frontend.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-12 19:03:02 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 2180e0480c add admin services-by-professional endpoint + calendar view in admin panel
Backend:
- GET /services/admin/professional/:id (JWT-guarded) returns paginated
  services for any professional with embedded user (client) info

Admin professional detail page:
- Replace broken all-services-then-filter with new endpoint
- Add month calendar with colored dots per day (click to filter table)
- Services table shows client name/phone, time, colored status badges
- Fix DAY_NAMES array (was 0=Domingo, now 0=Lunes per DB convention)
- Schedule row shows both ranges + continuous_day flag

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-12 18:58:42 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 8a97b52eb7 feat(services): add POST /services/block endpoint for slot blocking
- BlockSlotDto: validates day (ISO date) and range1_hour1 (HH:MM)
- ServicesService.blockSlot(): finds professional by userId, checks for
  conflicts (any non-cancelled/denied service at that slot), then creates
  a service with status=self_booked using the professional's own user_id
- ServicesController: POST /services/block (JWT-guarded) wired to blockSlot

Existing PATCH /services/:id/status → cancelled handles unblocking since
VALID_TRANSITIONS already allows self_booked → cancelled.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-12 18:26:57 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 c8b2c0c901 fix(services): add double-booking check and schedule validation on create
Before creating a service, now:
1. Checks if the requested slot (day + range1_hour1) is already occupied by
   a non-cancelled/denied service for that professional — rejects with 400
2. Validates the requested day has an enabled schedule for the professional;
   rejects if the professional does not work that weekday
3. Validates the requested hour falls within the configured hour ranges
   (continuous day: range1_hour1→range2_hour2; split day: morning + afternoon
   blocks) — rejects if outside configured hours

day_of_week conversion: JS Date.getDay() (0=Sun) → DB convention (0=Mon)
via (getDay()+6)%7

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-12 17:55:55 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 72777df52c fix: save and display professional profile address, payment methods, and docs
- DTO: add payment_methods field so whitelist pipe no longer strips it
- Service upsert(): extract payment_methods and write as Prisma nested upsert instead of passing flat object
- Admin detail page: fix image regex to handle Firebase URLs with query params; fix payment_methods display for 1:1 Prisma relation (object, not array)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-02 21:42:37 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 1e0de51ec5 feat(settings): add domicilios and tarifas toggles in admin panel
Both flags are read by prosappweb from /settings to enable/disable
delivery service and rate display. Saves via the existing PATCH /settings.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-02 20:48:10 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 4d98c7cb70 feat(auth): reject login and invalidate token for blocked users
Checks is_active on email login, phone OTP login, and /auth/me so
existing tokens also stop working immediately after a user is blocked.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-02 14:56:28 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 a8842a2728 feat(users): add block/unblock and delete from admin panel
- Migration 0004: adds is_active column to users table
- Backend: DELETE /users/:id endpoint + is_active field in UpdateUserDto
- Admin UI: block/unblock toggle and delete with confirmation on user detail
- Users list: shows "Bloqueado" badge for inactive users

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-02 14:55:16 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 04f1801b89 fix(entrypoint): let 0001 migration run so rethus columns are created on deploy
Baselining 0001 caused rethus_code/rethus_validated to never be added
automatically. The SQL already uses IF NOT EXISTS so it is safe to deploy
on every fresh container even if the columns already exist.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-02 14:34:46 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 57c159e91f fix(migrations): baseline existing tables + add 0003 for missing suggestions table
- entrypoint.sh: resolve --applied 0000 and 0001 before deploying,
  so prisma skips them and only runs 0002 (message_logs) and 0003 (suggestions)
- migration 0003: CREATE TABLE IF NOT EXISTS suggestions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-02 14:17:08 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 9703b1f08e fix(db): use postgres container name as host instead of IP, fix space in user
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-02 14:10:27 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 f6e34807ce fix(network): revert to bridge network — host mode breaks Docker internal routing to 10.0.1.9
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-02 13:59:52 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 46f3085432 fix(network): use host network mode so backend can reach LAN postgres at 10.0.1.9
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-02 13:42:05 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 54bc39bb34 fix(config): hardcode DATABASE_URL in entrypoint
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-02 13:28:28 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 e9e3d8141d fix(entrypoint): remove set -e, add verbose logging, don't block on migration failure
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-02 13:24:57 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 6288b9e43b chore(config): add DATABASE_URL to backend service in docker-compose
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-02 13:14:09 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 1e63ed1873 feat(db): idempotent migrations + auto-deploy on startup
- Rewrite 0000_init with IF NOT EXISTS on all tables, indexes and enums;
  wrap FK constraints in DO/EXCEPTION blocks so re-runs never fail
- Add missing suggestions table to init migration
- Add migration 0002 to create message_logs table (IF NOT EXISTS)
- Add entrypoint.sh: runs prisma migrate deploy before starting the server
- Update Dockerfile to copy entrypoint.sh and use it as CMD
- Add prisma:migrate script to package.json for manual runs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-02 13:10:33 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 749a1e860d feat(professionals): send FCM push notifications on status changes
Same events that trigger email (approve, deny, deactivate, setPending, upsert)
now also call tryPush() to send a silent FCM push to the professional's device.
NotificationsModule added to ProfessionalsModule imports.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-30 08:57:47 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 1f3efb50da Add SMTP test email endpoint and mail logs in admin settings
- POST /settings/test-email: send test email to diagnose SMTP config
- Admin settings: test email form + last 20 email logs with status/error

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 11:20:56 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 b0ca6804fa Fix JSON parse error on empty responses: handle empty body in API client and return message from saveSmtpConfig
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 11:12:13 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 27f4a2d7ec Add transactional emails: welcome, professional submission, status changes
- mail/mail.service.ts: MailService with HTML templates (welcome, submitted,
  approved, rejected, pending, deactivated). Silent if SMTP not configured.
- mail/mail.module.ts: global module so all services can inject MailService
- auth.service.ts: send welcome email on register()
- professionals.service.ts: notify admin on new submission; notify professional
  on approve/deny/deactivate/setPending

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 11:01:45 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 0a67f5d80a Add Verifik RETHUS integration: backend module, settings auth flow, admin query button
- verifik.service.ts: email OTP auth, token storage/refresh, RETHUS query by professional
- verifik.controller.ts: send-otp, confirm, refresh, rethus/professional/:id endpoints
- settings/page.tsx: Verifik connection section (email→OTP→token)
- professionals/[id]/page.tsx: Consultar RETHUS button with inline result display

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 10:50:35 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 66bc47b6e1 Fix UpdateProfessionalDto: add identification_picture and certificate_picture
These fields were missing from the DTO so NestJS/class-validator was stripping
them from PATCH /professionals/me requests before they reached Prisma.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 10:41:27 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 aa35f4b661 Always show documents section in professional detail, with No subido fallback
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 10:31:40 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 b0e3467565 Fix resetRejected: always reset pro_state even without professional record
Previous code deleted the professional record; if pro_state=3 but no record
exists, the 404 blocked the retry. Now we unconditionally reset pro_state=0.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 10:03:31 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 5f02c45759 Fix retry flow: reset pro_state without deleting professional record
- resetRejected: only set pro_state=0, no DELETE (avoids FK constraint errors
  from services/reputations with onDelete: NoAction)
- upsert: detect re-submission after rejection (pro_state=0) and set pro_state=1
  so the request appears in admin pending list

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-29 10:00:38 -05:00
Lizandro GuarnizoandClaude Sonnet 4.6 0899105a52 Add rejection retry flow with configurable wait days
- Backend: DELETE /professionals/me resets rejected state so user can retry
- Backend: findRejected/findPending now filter by pro_state
- Admin settings: rejection_wait_days field (default 7)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-28 18:03:13 -05:00
34 changed files with 1673 additions and 325 deletions
+336 -89
View File
@@ -10,10 +10,18 @@ import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { ArrowLeft, Pencil, X, Check, ShieldCheck, ShieldAlert, ExternalLink, Copy } from 'lucide-react'; import {
ArrowLeft, Pencil, X, Check, ShieldCheck, ShieldAlert,
ExternalLink, Copy, ChevronLeft, ChevronRight, Calendar,
} from 'lucide-react';
interface User { id: string; name: string; email?: string; phone?: string; city?: string; picture?: string; } interface User { id: string; name: string; email?: string; phone?: string; city?: string; picture?: string; gender?: string | null; }
interface Schedule { id: string; day_of_week: number; enabled: boolean; range1_hour1?: string; range1_hour2?: string; } // DB convention: day_of_week 0=Mon … 6=Sun
interface Schedule {
id: string; day_of_week: number; enabled: boolean; continuous_day?: boolean;
range1_hour1?: string; range1_hour2?: string;
range2_hour1?: string; range2_hour2?: string;
}
interface Specialization { id: string; name: string; } interface Specialization { id: string; name: string; }
interface PaymentMethod { id: string; nequi: boolean; datafono: boolean; transferencia: boolean; } interface PaymentMethod { id: string; nequi: boolean; datafono: boolean; transferencia: boolean; }
interface Professional { interface Professional {
@@ -23,13 +31,165 @@ interface Professional {
rate?: number; average_score?: number; rate?: number; average_score?: number;
identification_picture?: string; certificate_picture?: string; banner_picture?: string; identification_picture?: string; certificate_picture?: string; banner_picture?: string;
users?: User; schedules?: Schedule[]; users?: User; schedules?: Schedule[];
specializations?: Specialization[]; payment_methods?: PaymentMethod[]; specializations?: Specialization[];
payment_methods?: PaymentMethod | PaymentMethod[] | null;
}
interface ServiceItem {
id: string; description?: string; rate?: number; status: string;
day: string; range1_hour1?: string; range1_hour2?: string;
users?: { id: string; name: string; phone?: string; picture?: string } | null;
} }
interface Service { id: string; description?: string; rate?: number; status: string; day: string; }
const DAY_NAMES = ['Domingo', 'Lunes', 'Martes', 'Miércoles', 'Jueves', 'Viernes', 'Sábado']; // DB: 0=Mon … 6=Sun
const DAY_NAMES = ['Lunes', 'Martes', 'Miércoles', 'Jueves', 'Viernes', 'Sábado', 'Domingo'];
const PAYMENT_LABELS: Record<string, string> = { nequi: 'Nequi', datafono: 'Datáfono', transferencia: 'Transferencia' }; const PAYMENT_LABELS: Record<string, string> = { nequi: 'Nequi', datafono: 'Datáfono', transferencia: 'Transferencia' };
const STATUS_LABEL: Record<string, string> = {
pending: 'Pendiente', accepted: 'Aceptado', active: 'Activo',
completed: 'Completado', cancelled: 'Cancelado', denied: 'Rechazado', self_booked: 'Bloqueado',
};
const STATUS_CLASS: Record<string, string> = {
pending: 'bg-amber-100 text-amber-800 border-amber-200',
accepted: 'bg-blue-100 text-blue-800 border-blue-200',
active: 'bg-green-100 text-green-800 border-green-200',
completed: 'bg-emerald-700 text-white border-emerald-800',
cancelled: 'bg-red-100 text-red-800 border-red-200',
denied: 'bg-red-200 text-red-900 border-red-300',
self_booked: 'bg-orange-100 text-orange-800 border-orange-200',
};
function fmtTime(raw?: string | null): string {
if (!raw) return '';
// raw may be ISO date-time string from prisma (Date stored as time)
const match = raw.match(/T(\d{2}:\d{2})/);
if (match) return match[1];
// already "HH:MM"
if (/^\d{2}:\d{2}/.test(raw)) return raw.slice(0, 5);
return raw;
}
// ── Compact month calendar ──────────────────────────────────────────────────
function MonthCalendar({
services,
selectedDay,
onSelect,
}: {
services: ServiceItem[];
selectedDay: string | null;
onSelect: (day: string | null) => void;
}) {
const [cursor, setCursor] = useState(() => {
const now = new Date();
return new Date(now.getFullYear(), now.getMonth(), 1);
});
const year = cursor.getFullYear();
const month = cursor.getMonth();
const firstDow = new Date(year, month, 1).getDay(); // 0=Sun
// shift so Mon=0
const startOffset = (firstDow + 6) % 7;
const daysInMonth = new Date(year, month + 1, 0).getDate();
const servicesByDay = new Map<string, string[]>();
for (const s of services) {
const key = s.day.slice(0, 10);
if (!servicesByDay.has(key)) servicesByDay.set(key, []);
servicesByDay.get(key)!.push(s.status);
}
const monthNames = [
'Enero','Febrero','Marzo','Abril','Mayo','Junio',
'Julio','Agosto','Septiembre','Octubre','Noviembre','Diciembre',
];
function dotColor(statuses: string[]): string {
if (statuses.includes('active')) return 'bg-green-500';
if (statuses.includes('accepted')) return 'bg-blue-500';
if (statuses.includes('pending')) return 'bg-amber-500';
if (statuses.includes('self_booked')) return 'bg-orange-400';
if (statuses.includes('completed')) return 'bg-emerald-600';
return 'bg-gray-400';
}
const cells: (number | null)[] = [
...Array(startOffset).fill(null),
...Array.from({ length: daysInMonth }, (_, i) => i + 1),
];
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<button onClick={() => setCursor(new Date(year, month - 1, 1))} className="p-1 hover:bg-muted rounded">
<ChevronLeft className="h-4 w-4" />
</button>
<span className="font-semibold text-sm">{monthNames[month]} {year}</span>
<button onClick={() => setCursor(new Date(year, month + 1, 1))} className="p-1 hover:bg-muted rounded">
<ChevronRight className="h-4 w-4" />
</button>
</div>
<div className="grid grid-cols-7 gap-0.5 text-xs">
{['L','M','X','J','V','S','D'].map((d) => (
<div key={d} className="text-center text-muted-foreground font-medium py-1">{d}</div>
))}
{cells.map((day, i) => {
if (!day) return <div key={`e-${i}`} />;
const key = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
const statuses = servicesByDay.get(key);
const isSelected = selectedDay === key;
return (
<button
key={key}
onClick={() => onSelect(isSelected ? null : key)}
className={`relative flex flex-col items-center justify-center rounded py-1 text-xs font-medium transition-colors
${isSelected ? 'bg-primary text-primary-foreground' : statuses ? 'hover:bg-muted bg-muted/50' : 'hover:bg-muted text-muted-foreground'}`}
>
{day}
{statuses && !isSelected && (
<span className={`absolute bottom-0.5 h-1.5 w-1.5 rounded-full ${dotColor(statuses)}`} />
)}
</button>
);
})}
</div>
{selectedDay && (
<div className="text-xs text-muted-foreground text-center">
Filtrando: {selectedDay} {' '}
<button className="text-primary underline" onClick={() => onSelect(null)}>ver todos</button>
</div>
)}
</div>
);
}
// ── Schedule display helpers ────────────────────────────────────────────────
function ScheduleRow({ s }: { s: Schedule }) {
if (!s.enabled) {
return (
<TableRow key={s.id}>
<TableCell className="font-medium">{DAY_NAMES[s.day_of_week] ?? s.day_of_week}</TableCell>
<TableCell className="text-muted-foreground">Descanso</TableCell>
</TableRow>
);
}
let hours = '';
if (s.continuous_day) {
hours = `${fmtTime(s.range1_hour1)} ${fmtTime(s.range2_hour2)} (continuo)`;
} else {
const r1 = s.range1_hour1 ? `${fmtTime(s.range1_hour1)} ${fmtTime(s.range1_hour2)}` : '';
const r2 = s.range2_hour1 ? `${fmtTime(s.range2_hour1)} ${fmtTime(s.range2_hour2)}` : '';
hours = [r1, r2].filter(Boolean).join(' · ') || '—';
}
return (
<TableRow key={s.id}>
<TableCell className="font-medium">{DAY_NAMES[s.day_of_week] ?? s.day_of_week}</TableCell>
<TableCell>{hours}</TableCell>
</TableRow>
);
}
// ── Page ───────────────────────────────────────────────────────────────────
interface ProForm { profession: string; identification: string; address: string; rate: string; rethus_code: string; } interface ProForm { profession: string; identification: string; address: string; rate: string; rethus_code: string; }
interface UserForm { name: string; email: string; phone: string; city: string; } interface UserForm { name: string; email: string; phone: string; city: string; }
@@ -37,16 +197,18 @@ export default function ProfessionalDetailPage() {
const params = useParams<{ id: string }>(); const params = useParams<{ id: string }>();
const id = params?.id; const id = params?.id;
const [professional, setProfessional] = useState<Professional | null>(null); const [professional, setProfessional] = useState<Professional | null>(null);
const [services, setServices] = useState<Service[]>([]); const [services, setServices] = useState<ServiceItem[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [selectedDay, setSelectedDay] = useState<string | null>(null);
// Edit state
const [editingPro, setEditingPro] = useState(false); const [editingPro, setEditingPro] = useState(false);
const [editingUser, setEditingUser] = useState(false); const [editingUser, setEditingUser] = useState(false);
const [savingPro, setSavingPro] = useState(false); const [savingPro, setSavingPro] = useState(false);
const [savingUser, setSavingUser] = useState(false); const [savingUser, setSavingUser] = useState(false);
const [validatingRethus, setValidatingRethus] = useState(false); const [validatingRethus, setValidatingRethus] = useState(false);
const [rethusResult, setRethusResult] = useState<any>(null);
const [loadingRethus, setLoadingRethus] = useState(false);
const [proForm, setProForm] = useState<ProForm>({ profession: '', identification: '', address: '', rate: '', rethus_code: '' }); const [proForm, setProForm] = useState<ProForm>({ profession: '', identification: '', address: '', rate: '', rethus_code: '' });
const [userForm, setUserForm] = useState<UserForm>({ name: '', email: '', phone: '', city: '' }); const [userForm, setUserForm] = useState<UserForm>({ name: '', email: '', phone: '', city: '' });
@@ -56,7 +218,7 @@ export default function ProfessionalDetailPage() {
setError(null); setError(null);
Promise.all([ Promise.all([
api.get<Professional>(`/professionals/${id}`), api.get<Professional>(`/professionals/${id}`),
api.get<{ data: Service[] }>('/services?page=1&limit=50'), api.get<{ data: ServiceItem[]; meta: any }>(`/services/admin/professional/${id}?limit=200`),
]) ])
.then(([prof, svcRes]) => { .then(([prof, svcRes]) => {
setProfessional(prof); setProfessional(prof);
@@ -73,7 +235,7 @@ export default function ProfessionalDetailPage() {
phone: prof.users?.phone || '', phone: prof.users?.phone || '',
city: prof.users?.city || '', city: prof.users?.city || '',
}); });
setServices(svcRes.data.filter((s: any) => s.professional_id === id)); setServices(svcRes.data);
}) })
.catch(() => setError('Error al cargar el profesional')) .catch(() => setError('Error al cargar el profesional'))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
@@ -101,6 +263,19 @@ export default function ProfessionalDetailPage() {
} }
}; };
const consultRethus = async () => {
setLoadingRethus(true);
setRethusResult(null);
try {
const data = await api.get<any>(`/verifik/rethus/professional/${id}`);
setRethusResult(data);
} catch (e: any) {
toast.error(e?.message || 'Error al consultar RETHUS');
} finally {
setLoadingRethus(false);
}
};
const validateRethus = async () => { const validateRethus = async () => {
setValidatingRethus(true); setValidatingRethus(true);
try { try {
@@ -163,6 +338,13 @@ export default function ProfessionalDetailPage() {
); );
const user = professional.users; const user = professional.users;
const visibleServices = selectedDay
? services.filter((s) => s.day.slice(0, 10) === selectedDay)
: services;
const sortedSchedules = professional.schedules
? [...professional.schedules].sort((a, b) => a.day_of_week - b.day_of_week)
: [];
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -172,7 +354,7 @@ export default function ProfessionalDetailPage() {
<Link href="/professionals"><Button variant="ghost" size="icon"><ArrowLeft className="h-5 w-5" /></Button></Link> <Link href="/professionals"><Button variant="ghost" size="icon"><ArrowLeft className="h-5 w-5" /></Button></Link>
<div> <div>
<h1 className="text-2xl font-bold">{user?.name || 'Profesional'}</h1> <h1 className="text-2xl font-bold">{user?.name || 'Profesional'}</h1>
<p className="text-sm text-muted-foreground">ID profesional: {id}</p> <p className="text-sm text-muted-foreground">ID: {id}</p>
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -204,7 +386,7 @@ export default function ProfessionalDetailPage() {
</Button> </Button>
) : ( ) : (
<div className="flex gap-2"> <div className="flex gap-2">
<Button variant="outline" size="sm" onClick={() => { setEditingUser(false); }} disabled={savingUser}> <Button variant="outline" size="sm" onClick={() => setEditingUser(false)} disabled={savingUser}>
<X className="mr-1 h-4 w-4" /> Cancelar <X className="mr-1 h-4 w-4" /> Cancelar
</Button> </Button>
<Button size="sm" onClick={saveUser} disabled={savingUser}> <Button size="sm" onClick={saveUser} disabled={savingUser}>
@@ -237,6 +419,10 @@ export default function ProfessionalDetailPage() {
? <Input value={userForm.city} onChange={(e) => setUserForm({ ...userForm, city: e.target.value })} /> ? <Input value={userForm.city} onChange={(e) => setUserForm({ ...userForm, city: e.target.value })} />
: <p className="font-medium">{user?.city || '—'}</p>} : <p className="font-medium">{user?.city || '—'}</p>}
</div> </div>
<div className="space-y-1">
<span className="text-muted-foreground">Género</span>
<p className="font-medium capitalize">{user?.gender || '—'}</p>
</div>
</CardContent> </CardContent>
</Card> </Card>
@@ -326,7 +512,6 @@ export default function ProfessionalDetailPage() {
<button <button
onClick={() => { navigator.clipboard.writeText(professional.rethus_code!); toast.success('Código copiado'); }} onClick={() => { navigator.clipboard.writeText(professional.rethus_code!); toast.success('Código copiado'); }}
className="text-muted-foreground hover:text-foreground" className="text-muted-foreground hover:text-foreground"
title="Copiar código"
> >
<Copy className="h-3.5 w-3.5" /> <Copy className="h-3.5 w-3.5" />
</button> </button>
@@ -340,7 +525,6 @@ export default function ProfessionalDetailPage() {
<button <button
onClick={() => { navigator.clipboard.writeText(professional.identification!); toast.success('Cédula copiada'); }} onClick={() => { navigator.clipboard.writeText(professional.identification!); toast.success('Cédula copiada'); }}
className="text-muted-foreground hover:text-foreground" className="text-muted-foreground hover:text-foreground"
title="Copiar cédula"
> >
<Copy className="h-3.5 w-3.5" /> <Copy className="h-3.5 w-3.5" />
</button> </button>
@@ -349,29 +533,45 @@ export default function ProfessionalDetailPage() {
)} )}
</div> </div>
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
<Button <Button size="sm" variant="outline" onClick={consultRethus} disabled={loadingRethus}>
variant="outline" <ShieldCheck className="mr-2 h-4 w-4" />
size="sm" {loadingRethus ? 'Consultando...' : 'Consultar en RETHUS'}
onClick={() => window.open('https://www.minsalud.gov.co/salud/Paginas/rethus.aspx', '_blank')} </Button>
> <Button variant="outline" size="sm" onClick={() => window.open('https://www.minsalud.gov.co/salud/Paginas/rethus.aspx', '_blank')}>
<ExternalLink className="mr-2 h-4 w-4" /> <ExternalLink className="mr-2 h-4 w-4" /> Portal Minsalud
Abrir portal RETHUS (Minsalud)
</Button> </Button>
{!professional.rethus_validated && ( {!professional.rethus_validated && (
<Button <Button size="sm" onClick={validateRethus} disabled={validatingRethus} className="bg-green-600 hover:bg-green-700 text-white">
size="sm"
onClick={validateRethus}
disabled={validatingRethus}
className="bg-green-600 hover:bg-green-700 text-white"
>
<ShieldCheck className="mr-2 h-4 w-4" /> <ShieldCheck className="mr-2 h-4 w-4" />
{validatingRethus ? 'Guardando...' : 'Marcar como validado'} {validatingRethus ? 'Guardando...' : 'Marcar como validado'}
</Button> </Button>
)} )}
</div> </div>
{rethusResult && (
<div className="mt-3 rounded-lg border bg-muted/40 p-4 space-y-2 text-sm">
<p className="font-semibold">{rethusResult.fullName || `${rethusResult.firstName} ${rethusResult.lastName}`}</p>
<p><span className="text-muted-foreground">Estado: </span>
<span className={rethusResult.rethus?.status?.includes('ACTIVO') ? 'text-green-600 font-medium' : 'text-destructive font-medium'}>
{rethusResult.rethus?.status || '—'}
</span>
</p>
{rethusResult.rethus?.academic?.length > 0 && (
<div className="space-y-1">
<p className="text-muted-foreground font-medium">Títulos registrados:</p>
{rethusResult.rethus.academic.map((a: any, i: number) => (
<div key={i} className="pl-3 border-l-2 border-muted-foreground/30">
<p className="font-medium">{a.profession}</p>
<p className="text-xs text-muted-foreground">{a.type} · {a.originDegree} · {a.startDate}</p>
{a.entity && <p className="text-xs text-muted-foreground">{a.entity}</p>}
</div>
))}
</div>
)}
</div>
)}
{!professional.rethus_validated && ( {!professional.rethus_validated && (
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Copia el código o cédula, búscalo en el portal de Minsalud y una vez verificado haz clic en "Marcar como validado". Consulta automáticamente con Verifik o búscalo en el portal de Minsalud. Una vez verificado, haz clic en "Marcar como validado".
</p> </p>
)} )}
</> </>
@@ -381,69 +581,62 @@ export default function ProfessionalDetailPage() {
</CardContent> </CardContent>
</Card> </Card>
{/* Documentos subidos */} {/* Documentos */}
{(professional.identification_picture || professional.certificate_picture || professional.banner_picture) && (
<Card> <Card>
<CardHeader><CardTitle>Documentos y fotos</CardTitle></CardHeader> <CardHeader><CardTitle>Documentos y fotos</CardTitle></CardHeader>
<CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-3"> <CardContent className="grid grid-cols-1 gap-4 sm:grid-cols-3">
{professional.identification_picture && ( {(['identification_picture', 'certificate_picture', 'banner_picture'] as const).map((field) => {
<div className="space-y-2"> const labels: Record<string, string> = {
<p className="text-sm text-muted-foreground">Foto de cédula</p> identification_picture: 'Foto de cédula',
{professional.identification_picture.match(/\.(jpg|jpeg|png|webp)$/i) ? ( certificate_picture: 'Certificado / Diploma',
<a href={professional.identification_picture} target="_blank" rel="noreferrer"> banner_picture: 'Foto de perfil / Banner',
<img src={professional.identification_picture} alt="Cédula" className="rounded-lg border object-cover w-full max-h-48" /> };
const url = professional[field];
const isImage = url && /\.(jpg|jpeg|png|webp|gif)(\?|$)/i.test(url);
return (
<div key={field} className="space-y-2">
<p className="text-sm text-muted-foreground">{labels[field]}</p>
{url ? (
isImage ? (
<a href={url} target="_blank" rel="noreferrer">
<img src={url} alt={labels[field]} className="rounded-lg border object-cover w-full max-h-48" />
</a> </a>
) : ( ) : (
<a href={professional.identification_picture} target="_blank" rel="noreferrer" className="flex items-center gap-2 text-sm text-primary underline"> <a href={url} target="_blank" rel="noreferrer" className="flex items-center gap-2 text-sm text-primary underline">
<ExternalLink className="h-4 w-4" /> Ver documento <ExternalLink className="h-4 w-4" /> Ver documento
</a> </a>
)} )
</div>
)}
{professional.certificate_picture && (
<div className="space-y-2">
<p className="text-sm text-muted-foreground">Certificado / Diploma</p>
{professional.certificate_picture.match(/\.(jpg|jpeg|png|webp)$/i) ? (
<a href={professional.certificate_picture} target="_blank" rel="noreferrer">
<img src={professional.certificate_picture} alt="Certificado" className="rounded-lg border object-cover w-full max-h-48" />
</a>
) : ( ) : (
<a href={professional.certificate_picture} target="_blank" rel="noreferrer" className="flex items-center gap-2 text-sm text-primary underline"> <p className="text-sm text-muted-foreground italic">No subido</p>
<ExternalLink className="h-4 w-4" /> Ver documento
</a>
)} )}
</div> </div>
)} );
{professional.banner_picture && ( })}
<div className="space-y-2">
<p className="text-sm text-muted-foreground">Foto de perfil / Banner</p>
<a href={professional.banner_picture} target="_blank" rel="noreferrer">
<img src={professional.banner_picture} alt="Banner" className="rounded-lg border object-cover w-full max-h-48" />
</a>
</div>
)}
</CardContent> </CardContent>
</Card> </Card>
)}
{/* Métodos de pago */} {/* Métodos de pago */}
{professional.payment_methods && professional.payment_methods.length > 0 && ( {professional.payment_methods && (
<Card> <Card>
<CardHeader><CardTitle>Métodos de pago aceptados</CardTitle></CardHeader> <CardHeader><CardTitle>Métodos de pago aceptados</CardTitle></CardHeader>
<CardContent> <CardContent>
<div className="flex flex-wrap gap-2"> {(() => {
{['nequi', 'datafono', 'transferencia'] const pm = Array.isArray(professional.payment_methods)
.filter((k) => (professional.payment_methods![0] as any)[k]) ? (professional.payment_methods as PaymentMethod[])[0]
.map((k) => <Badge key={k} variant="outline">{PAYMENT_LABELS[k]}</Badge>)} : professional.payment_methods as PaymentMethod;
</div> const active = pm ? ['nequi', 'datafono', 'transferencia'].filter((k) => (pm as any)[k]) : [];
return active.length > 0
? <div className="flex flex-wrap gap-2">{active.map((k) => <Badge key={k} variant="outline">{PAYMENT_LABELS[k]}</Badge>)}</div>
: <p className="text-sm text-muted-foreground">Ninguno configurado</p>;
})()}
</CardContent> </CardContent>
</Card> </Card>
)} )}
{/* Horarios */} {/* Horarios */}
{professional.schedules && professional.schedules.length > 0 && ( {sortedSchedules.length > 0 && (
<Card> <Card>
<CardHeader><CardTitle>Horarios</CardTitle></CardHeader> <CardHeader><CardTitle>Horarios configurados</CardTitle></CardHeader>
<CardContent> <CardContent>
<Table> <Table>
<TableHeader> <TableHeader>
@@ -453,16 +646,7 @@ export default function ProfessionalDetailPage() {
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{professional.schedules.map((s) => ( {sortedSchedules.map((s) => <ScheduleRow key={s.id} s={s} />)}
<TableRow key={s.id}>
<TableCell>{DAY_NAMES[s.day_of_week] ?? s.day_of_week}</TableCell>
<TableCell>
{s.enabled
? [s.range1_hour1, s.range1_hour2].filter(Boolean).join(' ') || '—'
: <span className="text-muted-foreground">Descanso</span>}
</TableCell>
</TableRow>
))}
</TableBody> </TableBody>
</Table> </Table>
</CardContent> </CardContent>
@@ -481,39 +665,102 @@ export default function ProfessionalDetailPage() {
</Card> </Card>
)} )}
{/* Servicios */} {/* Calendario + Servicios agendados */}
{services.length > 0 && (
<Card> <Card>
<CardHeader><CardTitle>Servicios recientes</CardTitle></CardHeader> <CardHeader>
<CardTitle className="flex items-center gap-2">
<Calendar className="h-5 w-5" />
Servicios agendados
<Badge variant="outline" className="ml-auto text-xs">{services.length} total</Badge>
</CardTitle>
</CardHeader>
<CardContent> <CardContent>
<div className="flex flex-col lg:flex-row gap-6">
{/* Calendar */}
<div className="lg:w-64 shrink-0">
<MonthCalendar
services={services}
selectedDay={selectedDay}
onSelect={setSelectedDay}
/>
{/* Legend */}
<div className="mt-4 space-y-1 text-xs text-muted-foreground">
{[
{ color: 'bg-amber-500', label: 'Pendiente' },
{ color: 'bg-blue-500', label: 'Aceptado' },
{ color: 'bg-green-500', label: 'Activo' },
{ color: 'bg-emerald-600', label: 'Completado' },
{ color: 'bg-orange-400', label: 'Bloqueado' },
{ color: 'bg-gray-400', label: 'Cancelado / Rechazado' },
].map(({ color, label }) => (
<div key={label} className="flex items-center gap-2">
<span className={`h-2 w-2 rounded-full ${color}`} />
{label}
</div>
))}
</div>
</div>
{/* Services table */}
<div className="flex-1 overflow-auto">
{visibleServices.length === 0 ? (
<p className="text-sm text-muted-foreground py-4">
{selectedDay ? 'Sin servicios este día.' : 'Sin servicios registrados.'}
</p>
) : (
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>Fecha</TableHead> <TableHead>Fecha</TableHead>
<TableHead>Descripción</TableHead> <TableHead>Hora</TableHead>
<TableHead>Cliente</TableHead>
<TableHead>Tarifa</TableHead> <TableHead>Tarifa</TableHead>
<TableHead>Estado</TableHead> <TableHead>Estado</TableHead>
<TableHead>Descripción</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{services.map((s) => ( {visibleServices.map((s) => (
<TableRow key={s.id}> <TableRow key={s.id}>
<TableCell>{new Date(s.day).toLocaleDateString()}</TableCell> <TableCell className="whitespace-nowrap">
<TableCell>{s.description || '—'}</TableCell> {new Date(s.day).toLocaleDateString('es-CO', { day: '2-digit', month: 'short', year: 'numeric' })}
</TableCell>
<TableCell className="whitespace-nowrap font-mono text-sm">
{fmtTime(s.range1_hour1) || '—'}
</TableCell>
<TableCell>
{s.users ? (
<div>
<p className="font-medium text-sm">{s.users.name}</p>
{s.users.phone && <p className="text-xs text-muted-foreground">{s.users.phone}</p>}
</div>
) : (
<span className="text-muted-foreground text-sm"></span>
)}
</TableCell>
<TableCell>{s.rate != null ? `$${s.rate}` : '—'}</TableCell> <TableCell>{s.rate != null ? `$${s.rate}` : '—'}</TableCell>
<TableCell>{s.status}</TableCell> <TableCell>
<span className={`inline-flex items-center rounded border px-2 py-0.5 text-xs font-medium ${STATUS_CLASS[s.status] || 'bg-gray-100 text-gray-700 border-gray-200'}`}>
{STATUS_LABEL[s.status] || s.status}
</span>
</TableCell>
<TableCell className="text-sm text-muted-foreground max-w-48 truncate">
{s.description || '—'}
</TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>
</Table> </Table>
)}
</div>
</div>
</CardContent> </CardContent>
</Card> </Card>
)}
{/* Aprobar / Rechazar */} {/* Approve / Deny bottom CTA */}
{!professional.is_active && ( {!professional.is_active && (
<div className="flex gap-3 pt-2"> <div className="flex gap-3 pt-2">
<Button onClick={approve}>Aprobar profesional</Button> <Button onClick={approve} className="bg-green-600 hover:bg-green-700 text-white">Aprobar profesional</Button>
<Button variant="destructive" onClick={deny}>Rechazar solicitud</Button> <Button variant="destructive" onClick={deny}>Rechazar solicitud</Button>
</div> </div>
)} )}
+253 -2
View File
@@ -9,7 +9,7 @@ import { Badge } from '@/components/ui/badge';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { import {
Save, ExternalLink, Copy, FileText, Shield, Map, Save, ExternalLink, Copy, FileText, Shield, Map,
Eye, EyeOff, CheckCircle2, XCircle, Clock, Smartphone, Phone, Mail, Server, Eye, EyeOff, CheckCircle2, XCircle, Clock, Smartphone, Phone, Mail, Server, Link,
} from 'lucide-react'; } from 'lucide-react';
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/api/v1'; const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000/api/v1';
@@ -57,10 +57,20 @@ export default function SettingsPage() {
const [policies, setPolicies] = useState<Record<string, string>>({ privacy: '', terms: '' }); const [policies, setPolicies] = useState<Record<string, string>>({ privacy: '', terms: '' });
const [savingPolicy, setSavingPolicy] = useState<string | null>(null); const [savingPolicy, setSavingPolicy] = useState<string | null>(null);
// Verifik
const [verifikConnected, setVerifikConnected] = useState(false);
const [verifikEmail, setVerifikEmail] = useState('');
const [verifikOtp, setVerifikOtp] = useState('');
const [verifikOtpSent, setVerifikOtpSent] = useState(false);
const [verifikLoading, setVerifikLoading] = useState(false);
// SMTP // SMTP
const [smtp, setSmtp] = useState<SmtpConfig>({ host: '', port: '587', user: '', pass: '', from: '' }); const [smtp, setSmtp] = useState<SmtpConfig>({ host: '', port: '587', user: '', pass: '', from: '' });
const [showSmtpPass, setShowSmtpPass] = useState(false); const [showSmtpPass, setShowSmtpPass] = useState(false);
const [savingSmtp, setSavingSmtp] = useState(false); const [savingSmtp, setSavingSmtp] = useState(false);
const [testEmailTo, setTestEmailTo] = useState('');
const [sendingTest, setSendingTest] = useState(false);
const [mailLogs, setMailLogs] = useState<{ id: string; recipient: string; body: string; status: string; error?: string; created_at: string }[]>([]);
const load = useCallback(() => { const load = useCallback(() => {
setLoading(true); setLoading(true);
@@ -69,12 +79,16 @@ export default function SettingsPage() {
api.get<{ configured: boolean }>('/settings/maps-key'), api.get<{ configured: boolean }>('/settings/maps-key'),
api.get<{ privacy: string | null; terms: string | null }>('/settings/policies'), api.get<{ privacy: string | null; terms: string | null }>('/settings/policies'),
api.get<SmtpConfig | null>('/settings/smtp').catch(() => null), api.get<SmtpConfig | null>('/settings/smtp').catch(() => null),
api.get<{ connected: boolean }>('/verifik/status').catch(() => ({ connected: false })),
api.get<{ data: any[] }>('/settings/message-logs?limit=20&channel=email').catch(() => ({ data: [] })),
]) ])
.then(([globalData, mapsData, policiesData, smtpData]) => { .then(([globalData, mapsData, policiesData, smtpData, verifikStatus, logsData]) => {
setGlobal(globalData || {}); setGlobal(globalData || {});
setMapsConfigured(mapsData.configured); setMapsConfigured(mapsData.configured);
setPolicies({ privacy: policiesData.privacy || '', terms: policiesData.terms || '' }); setPolicies({ privacy: policiesData.privacy || '', terms: policiesData.terms || '' });
if (smtpData) setSmtp({ host: smtpData.host || '', port: smtpData.port || '587', user: smtpData.user || '', pass: smtpData.pass || '', from: smtpData.from || '' }); if (smtpData) setSmtp({ host: smtpData.host || '', port: smtpData.port || '587', user: smtpData.user || '', pass: smtpData.pass || '', from: smtpData.from || '' });
setVerifikConnected(verifikStatus.connected);
setMailLogs(logsData.data || []);
}) })
.catch(() => toast.error('Error al cargar configuración')) .catch(() => toast.error('Error al cargar configuración'))
.finally(() => setLoading(false)); .finally(() => setLoading(false));
@@ -134,6 +148,62 @@ export default function SettingsPage() {
} }
}; };
const sendTestEmail = async () => {
if (!testEmailTo.trim()) return toast.error('Ingresa un correo destino');
setSendingTest(true);
try {
await api.post('/settings/test-email', { to: testEmailTo.trim() });
toast.success(`Correo de prueba enviado a ${testEmailTo}`);
load(); // refresh logs
} catch (e: any) {
toast.error(e?.message || 'Error al enviar correo de prueba');
} finally {
setSendingTest(false);
}
};
const sendVerifikOtp = async () => {
if (!verifikEmail.trim()) return toast.error('Ingresa tu email de Verifik');
setVerifikLoading(true);
try {
await api.post('/verifik/send-otp', { email: verifikEmail.trim() });
setVerifikOtpSent(true);
toast.success('OTP enviado a tu correo');
} catch (e: any) {
toast.error(e?.message || 'Error al enviar OTP');
} finally {
setVerifikLoading(false);
}
};
const confirmVerifikOtp = async () => {
if (!verifikOtp.trim()) return toast.error('Ingresa el código OTP');
setVerifikLoading(true);
try {
await api.post('/verifik/confirm', { email: verifikEmail.trim(), otp: verifikOtp.trim() });
setVerifikConnected(true);
setVerifikOtpSent(false);
setVerifikOtp('');
toast.success('Verifik conectado correctamente');
} catch (e: any) {
toast.error(e?.message || 'Error al confirmar OTP');
} finally {
setVerifikLoading(false);
}
};
const refreshVerifikToken = async () => {
setVerifikLoading(true);
try {
await api.post('/verifik/refresh', {});
toast.success('Token renovado');
} catch (e: any) {
toast.error(e?.message || 'Error al renovar token');
} finally {
setVerifikLoading(false);
}
};
const copyLink = (key: string) => { const copyLink = (key: string) => {
navigator.clipboard.writeText(`${API_BASE}/settings/policy/${key}`); navigator.clipboard.writeText(`${API_BASE}/settings/policy/${key}`);
toast.success('Enlace copiado'); toast.success('Enlace copiado');
@@ -245,6 +315,37 @@ export default function SettingsPage() {
</CardContent> </CardContent>
</Card> </Card>
<Card>
<CardHeader>
<CardTitle className="text-base">Funcionalidades de la app</CardTitle>
<CardDescription>Activa o desactiva funciones para todos los usuarios.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{[
{ key: 'domicilios', label: 'Servicio a domicilio', description: 'Permite que los profesionales ofrezcan y los usuarios soliciten servicios a domicilio.' },
{ key: 'tarifas', label: 'Mostrar tarifas', description: 'Muestra las tarifas de los profesionales en su perfil y en las búsquedas.' },
].map(({ key, label, description }) => (
<div key={key} className="flex items-center justify-between gap-4 py-1">
<div>
<p className="text-sm font-medium">{label}</p>
<p className="text-xs text-muted-foreground">{description}</p>
</div>
<div className="flex items-center gap-3 shrink-0">
<button
onClick={() => setG(key, !global[key])}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${global[key] ? 'bg-[#42A4EF]' : 'bg-muted-foreground/30'}`}
>
<span className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${global[key] ? 'translate-x-6' : 'translate-x-1'}`} />
</button>
<Badge variant={global[key] ? 'default' : 'secondary'} className={global[key] ? 'bg-[#42A4EF]' : ''}>
{global[key] ? 'Activo' : 'Inactivo'}
</Badge>
</div>
</div>
))}
</CardContent>
</Card>
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle className="text-base">Modo mantenimiento</CardTitle> <CardTitle className="text-base">Modo mantenimiento</CardTitle>
@@ -263,6 +364,32 @@ export default function SettingsPage() {
</CardContent> </CardContent>
</Card> </Card>
{/* Solicitudes de profesional */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Clock size={18} className="text-muted-foreground" />
Solicitudes de profesional
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-1">
<label className="text-sm text-muted-foreground">Días de espera para reintentar tras rechazo</label>
<div className="flex items-center gap-2">
<Input
type="number"
min={0}
className="w-28"
value={global.rejection_wait_days ?? 7}
onChange={(e) => setG('rejection_wait_days', Number(e.target.value))}
/>
<span className="text-sm text-muted-foreground">días</span>
</div>
<p className="text-xs text-muted-foreground">Con 0 el usuario puede reintentar inmediatamente.</p>
</div>
</CardContent>
</Card>
<Button onClick={saveGlobal} disabled={savingGlobal}> <Button onClick={saveGlobal} disabled={savingGlobal}>
<Save className="mr-1 h-4 w-4" /> <Save className="mr-1 h-4 w-4" />
{savingGlobal ? 'Guardando...' : 'Guardar ajustes generales'} {savingGlobal ? 'Guardando...' : 'Guardar ajustes generales'}
@@ -392,6 +519,130 @@ export default function SettingsPage() {
</Card> </Card>
</section> </section>
{/* ── Test de correo + Logs ── */}
<section className="space-y-4">
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Mail size={18} className="text-muted-foreground" />
Probar configuración SMTP
</CardTitle>
<CardDescription>Envía un correo de prueba para verificar que el servidor SMTP funciona.</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex gap-2">
<Input
type="email"
placeholder="destino@ejemplo.com"
value={testEmailTo}
onChange={(e) => setTestEmailTo(e.target.value)}
className="flex-1"
/>
<Button onClick={sendTestEmail} disabled={sendingTest}>
{sendingTest ? 'Enviando...' : 'Enviar prueba'}
</Button>
</div>
{mailLogs.length > 0 && (
<div className="space-y-2">
<p className="text-sm font-medium text-muted-foreground">Últimos 20 correos enviados</p>
<div className="rounded-lg border overflow-hidden">
<table className="w-full text-xs">
<thead className="bg-muted/50">
<tr>
<th className="text-left px-3 py-2 font-medium">Destinatario</th>
<th className="text-left px-3 py-2 font-medium">Asunto</th>
<th className="text-left px-3 py-2 font-medium">Estado</th>
<th className="text-left px-3 py-2 font-medium">Fecha</th>
</tr>
</thead>
<tbody>
{mailLogs.map((log) => (
<tr key={log.id} className="border-t hover:bg-muted/30">
<td className="px-3 py-2 text-muted-foreground">{log.recipient}</td>
<td className="px-3 py-2 max-w-[200px] truncate">{log.body}</td>
<td className="px-3 py-2">
<span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${
log.status === 'sent' ? 'bg-green-100 text-green-700' : 'bg-red-100 text-red-700'
}`}>
{log.status === 'sent' ? '✓ enviado' : '✗ error'}
</span>
{log.error && <p className="text-xs text-destructive mt-0.5 truncate max-w-[180px]" title={log.error}>{log.error}</p>}
</td>
<td className="px-3 py-2 text-muted-foreground whitespace-nowrap">
{new Date(log.created_at).toLocaleString('es-CO', { dateStyle: 'short', timeStyle: 'short' })}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</CardContent>
</Card>
</section>
{/* ── Verifik ── */}
<section className="space-y-4">
<h2 className="text-lg font-semibold">Verifik Validación de profesionales</h2>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Link size={18} className="text-muted-foreground" />
Cuenta Verifik
</CardTitle>
<CardDescription>
Permite consultar RETHUS (registro de profesionales de salud) directamente desde el perfil de cada profesional.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center gap-3">
{verifikConnected
? <><CheckCircle2 className="text-green-500 h-5 w-5" /><span className="text-sm font-medium text-green-600">Conectado</span></>
: <><XCircle className="text-destructive h-5 w-5" /><span className="text-sm font-medium text-destructive">No conectado</span></>}
</div>
{!verifikOtpSent ? (
<div className="flex gap-2">
<Input
type="email"
placeholder="tu@email.com (cuenta Verifik)"
value={verifikEmail}
onChange={(e) => setVerifikEmail(e.target.value)}
className="flex-1"
/>
<Button onClick={sendVerifikOtp} disabled={verifikLoading || !verifikEmail.trim()}>
{verifikLoading ? 'Enviando...' : 'Enviar OTP'}
</Button>
</div>
) : (
<div className="space-y-3">
<p className="text-sm text-muted-foreground">Ingresa el código que llegó a <strong>{verifikEmail}</strong></p>
<div className="flex gap-2">
<Input
placeholder="Código OTP"
value={verifikOtp}
onChange={(e) => setVerifikOtp(e.target.value)}
className="w-40 font-mono"
/>
<Button onClick={confirmVerifikOtp} disabled={verifikLoading}>
{verifikLoading ? 'Confirmando...' : 'Confirmar'}
</Button>
<Button variant="ghost" onClick={() => setVerifikOtpSent(false)}>Cancelar</Button>
</div>
</div>
)}
{verifikConnected && (
<Button variant="outline" size="sm" onClick={refreshVerifikToken} disabled={verifikLoading}>
{verifikLoading ? 'Renovando...' : 'Renovar token (30 días)'}
</Button>
)}
</CardContent>
</Card>
</section>
{/* ── Documentos legales ── */} {/* ── Documentos legales ── */}
<section className="space-y-4"> <section className="space-y-4">
<h2 className="text-lg font-semibold">Documentos legales</h2> <h2 className="text-lg font-semibold">Documentos legales</h2>
+85 -5
View File
@@ -1,14 +1,14 @@
'use client'; 'use client';
import { useEffect, useState, useCallback } from 'react'; import { useEffect, useState, useCallback } from 'react';
import { useParams } from 'next/navigation'; import { useParams, useRouter } from 'next/navigation';
import Link from 'next/link'; import Link from 'next/link';
import { api } from '@/lib/api'; import { api } from '@/lib/api';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { ArrowLeft, Pencil, X, Check } from 'lucide-react'; import { ArrowLeft, Pencil, X, Check, ShieldOff, Shield, Trash2 } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
interface Professional { id: string; profession?: string; rate?: number; identification?: string; } interface Professional { id: string; profession?: string; rate?: number; identification?: string; }
@@ -16,7 +16,7 @@ interface Reputation { total: number; average: number; total_pro: number; averag
interface UserDetail { interface UserDetail {
id: string; name: string; email?: string; phone?: string; city?: string; id: string; name: string; email?: string; phone?: string; city?: string;
gender?: string; birthday?: string; is_email_verified?: boolean; gender?: string; birthday?: string; is_email_verified?: boolean;
is_phone_verified?: boolean; pro_state?: number; created_at: string; is_phone_verified?: boolean; is_active?: boolean; pro_state?: number; created_at: string;
professionals?: Professional | null; reputations?: Reputation | null; professionals?: Professional | null; reputations?: Reputation | null;
} }
@@ -29,12 +29,16 @@ interface Form { name: string; city: string; phone: string; gender: string; }
export default function UserDetailPage() { export default function UserDetailPage() {
const params = useParams<{ id: string }>(); const params = useParams<{ id: string }>();
const router = useRouter();
const id = params?.id; const id = params?.id;
const [user, setUser] = useState<UserDetail | null>(null); const [user, setUser] = useState<UserDetail | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [toggling, setToggling] = useState(false);
const [deleting, setDeleting] = useState(false);
const [confirmDelete, setConfirmDelete] = useState(false);
const [form, setForm] = useState<Form>({ name: '', city: '', phone: '', gender: '' }); const [form, setForm] = useState<Form>({ name: '', city: '', phone: '', gender: '' });
const load = useCallback(() => { const load = useCallback(() => {
@@ -71,6 +75,34 @@ export default function UserDetailPage() {
} }
}; };
const toggleBlock = async () => {
if (!user) return;
setToggling(true);
try {
const updated = await api.patch<UserDetail>(`/users/${id}`, { is_active: !user.is_active });
setUser(updated);
toast.success(updated.is_active ? 'Usuario desbloqueado' : 'Usuario bloqueado');
} catch (e: any) {
toast.error(e?.message || 'Error al cambiar estado');
} finally {
setToggling(false);
}
};
const deleteUser = async () => {
setDeleting(true);
try {
await api.delete(`/users/${id}`);
toast.success('Usuario eliminado');
router.push('/users');
} catch (e: any) {
toast.error(e?.message || 'No se puede eliminar: el usuario tiene datos asociados');
setConfirmDelete(false);
} finally {
setDeleting(false);
}
};
if (loading) return <div className="flex items-center justify-center py-16 text-muted-foreground">Cargando...</div>; if (loading) return <div className="flex items-center justify-center py-16 text-muted-foreground">Cargando...</div>;
if (error) return ( if (error) return (
<div className="flex flex-col items-center justify-center py-16 text-destructive gap-2"> <div className="flex flex-col items-center justify-center py-16 text-destructive gap-2">
@@ -81,18 +113,57 @@ export default function UserDetailPage() {
if (!user) return null; if (!user) return null;
const proState = user.pro_state ?? 0; const proState = user.pro_state ?? 0;
const isActive = user.is_active !== false;
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between flex-wrap gap-3">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Link href="/users"><Button variant="ghost" size="icon"><ArrowLeft className="h-5 w-5" /></Button></Link> <Link href="/users"><Button variant="ghost" size="icon"><ArrowLeft className="h-5 w-5" /></Button></Link>
<div> <div>
<div className="flex items-center gap-2">
<h1 className="text-2xl font-bold">{user.name}</h1> <h1 className="text-2xl font-bold">{user.name}</h1>
{!isActive && <Badge variant="destructive">Bloqueado</Badge>}
</div>
<p className="text-sm text-muted-foreground">ID: {user.id}</p> <p className="text-sm text-muted-foreground">ID: {user.id}</p>
</div> </div>
</div> </div>
<div className="flex items-center gap-2 flex-wrap">
{/* Bloquear / Desbloquear */}
<Button
variant={isActive ? 'outline' : 'secondary'}
size="sm"
onClick={toggleBlock}
disabled={toggling}
className={isActive ? 'border-orange-300 text-orange-600 hover:bg-orange-50' : ''}
>
{isActive
? <><ShieldOff className="mr-1 h-4 w-4" />{toggling ? 'Bloqueando...' : 'Bloquear'}</>
: <><Shield className="mr-1 h-4 w-4" />{toggling ? 'Desbloqueando...' : 'Desbloquear'}</>
}
</Button>
{/* Eliminar */}
{!confirmDelete ? (
<Button variant="outline" size="sm" onClick={() => setConfirmDelete(true)}
className="border-red-300 text-red-600 hover:bg-red-50">
<Trash2 className="mr-1 h-4 w-4" /> Eliminar
</Button>
) : (
<div className="flex items-center gap-1 rounded-md border border-red-300 bg-red-50 px-3 py-1.5">
<span className="text-xs text-red-600 font-medium mr-1">¿Confirmar?</span>
<Button variant="destructive" size="sm" onClick={deleteUser} disabled={deleting} className="h-7 px-2 text-xs">
{deleting ? 'Eliminando...' : 'Sí, eliminar'}
</Button>
<Button variant="ghost" size="sm" onClick={() => setConfirmDelete(false)} className="h-7 px-2 text-xs">
No
</Button>
</div>
)}
{/* Editar */}
{!editing ? ( {!editing ? (
<Button variant="outline" size="sm" onClick={() => setEditing(true)}> <Button variant="outline" size="sm" onClick={() => setEditing(true)}>
<Pencil className="mr-1 h-4 w-4" /> Editar <Pencil className="mr-1 h-4 w-4" /> Editar
@@ -108,6 +179,7 @@ export default function UserDetailPage() {
</div> </div>
)} )}
</div> </div>
</div>
<div className="grid grid-cols-1 gap-6 md:grid-cols-2"> <div className="grid grid-cols-1 gap-6 md:grid-cols-2">
{/* Info general */} {/* Info general */}
@@ -170,6 +242,14 @@ export default function UserDetailPage() {
<Card> <Card>
<CardHeader><CardTitle>Estado</CardTitle></CardHeader> <CardHeader><CardTitle>Estado</CardTitle></CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div>
<span className="text-sm text-muted-foreground">Cuenta</span>
<div className="mt-2">
<Badge variant={isActive ? 'default' : 'destructive'}>
{isActive ? 'Activo' : 'Bloqueado'}
</Badge>
</div>
</div>
<div> <div>
<span className="text-sm text-muted-foreground">Estado profesional</span> <span className="text-sm text-muted-foreground">Estado profesional</span>
<div className="mt-2"> <div className="mt-2">
@@ -201,7 +281,7 @@ export default function UserDetailPage() {
</Card> </Card>
</div> </div>
{/* Perfil profesional (solo lectura, con link) */} {/* Perfil profesional */}
{user.professionals && ( {user.professionals && (
<Card> <Card>
<CardHeader> <CardHeader>
+8 -1
View File
@@ -7,6 +7,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Search, Eye } from 'lucide-react'; import { Search, Eye } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import Link from 'next/link'; import Link from 'next/link';
interface User { interface User {
@@ -15,6 +16,7 @@ interface User {
email?: string; email?: string;
phone?: string; phone?: string;
city?: string; city?: string;
is_active?: boolean;
pro_state?: number; pro_state?: number;
created_at: string; created_at: string;
} }
@@ -91,7 +93,12 @@ export default function UsersPage() {
<TableCell>{u.email || '—'}</TableCell> <TableCell>{u.email || '—'}</TableCell>
<TableCell>{u.phone || '—'}</TableCell> <TableCell>{u.phone || '—'}</TableCell>
<TableCell>{u.city || '—'}</TableCell> <TableCell>{u.city || '—'}</TableCell>
<TableCell>{['Usuario', 'Solicitó', 'Profesional', 'Rechazado'][u.pro_state ?? 0] || '—'}</TableCell> <TableCell>
{u.is_active === false
? <Badge variant="destructive">Bloqueado</Badge>
: <span>{['Usuario', 'Solicitó', 'Profesional', 'Rechazado'][u.pro_state ?? 0] || '—'}</span>
}
</TableCell>
<TableCell>{new Date(u.created_at).toLocaleDateString()}</TableCell> <TableCell>{new Date(u.created_at).toLocaleDateString()}</TableCell>
<TableCell> <TableCell>
<Link href={`/users/${u.id}`}> <Link href={`/users/${u.id}`}>
+2 -1
View File
@@ -24,7 +24,8 @@ async function request<T>(path: string, options?: RequestInit): Promise<T> {
throw new ApiError(res.status, body.message || 'Error de servidor'); throw new ApiError(res.status, body.message || 'Error de servidor');
} }
return res.json(); const text = await res.text();
return text ? JSON.parse(text) : ({} as T);
} }
export const api = { export const api = {
+4 -1
View File
@@ -12,5 +12,8 @@ COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./ COPY --from=builder /app/package.json ./
COPY --from=builder /app/prisma ./prisma COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/prisma.config.js ./
COPY --from=builder /app/entrypoint.sh ./
RUN chmod +x entrypoint.sh
EXPOSE 3001 EXPOSE 3001
CMD ["node", "dist/main.js"] CMD ["sh", "entrypoint.sh"]
+21
View File
@@ -0,0 +1,21 @@
#!/bin/sh
DATABASE_URL="postgresql://prosapp_user:ProsappPass123!@wkuvmdsy39relyhnugk87eqb:5432/prosapp"
export DATABASE_URL
PRISMA="node_modules/.bin/prisma"
echo "=== ProsApp Backend Starting ==="
# Baseline: mark existing migrations as applied without running their SQL.
# This creates _prisma_migrations if it doesn't exist, then records each
# migration so prisma migrate deploy skips them and only runs new ones.
echo "Baselining existing migrations..."
$PRISMA migrate resolve --applied "0000_init" 2>/dev/null || true
# Apply only new/pending migrations (0002, 0003, ...)
echo "Applying pending migrations..."
$PRISMA migrate deploy
echo "Starting server..."
exec node dist/main.js
+1
View File
@@ -11,6 +11,7 @@
"start:prod": "node dist/main", "start:prod": "node dist/main",
"prisma:generate": "prisma generate", "prisma:generate": "prisma generate",
"prisma:pull": "prisma db pull", "prisma:pull": "prisma db pull",
"prisma:migrate": "prisma migrate deploy",
"lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix" "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix"
}, },
"keywords": [], "keywords": [],
+122 -138
View File
@@ -1,16 +1,19 @@
Loaded Prisma config from prisma.config.js.
-- CreateSchema -- CreateSchema
CREATE SCHEMA IF NOT EXISTS "public"; CREATE SCHEMA IF NOT EXISTS "public";
-- CreateEnum -- CreateEnum
CREATE TYPE "service_location" AS ENUM ('office', 'delivery'); DO $$ BEGIN
CREATE TYPE "service_location" AS ENUM ('office', 'delivery');
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
-- CreateEnum DO $$ BEGIN
CREATE TYPE "service_status" AS ENUM ('pending', 'accepted', 'denied', 'active', 'cancelled', 'completed', 'self_booked'); CREATE TYPE "service_status" AS ENUM ('pending', 'accepted', 'denied', 'active', 'cancelled', 'completed', 'self_booked');
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
-- CreateTable -- CreateTable
CREATE TABLE "chats" ( CREATE TABLE IF NOT EXISTS "chats" (
"id" UUID NOT NULL DEFAULT uuid_generate_v4(), "id" UUID NOT NULL DEFAULT uuid_generate_v4(),
"user_id" UUID NOT NULL, "user_id" UUID NOT NULL,
"professional_id" UUID NOT NULL, "professional_id" UUID NOT NULL,
@@ -19,8 +22,7 @@ CREATE TABLE "chats" (
CONSTRAINT "chats_pkey" PRIMARY KEY ("id") CONSTRAINT "chats_pkey" PRIMARY KEY ("id")
); );
-- CreateTable CREATE TABLE IF NOT EXISTS "cities" (
CREATE TABLE "cities" (
"id" UUID NOT NULL DEFAULT uuid_generate_v4(), "id" UUID NOT NULL DEFAULT uuid_generate_v4(),
"region_id" UUID NOT NULL, "region_id" UUID NOT NULL,
"name" VARCHAR(255) NOT NULL, "name" VARCHAR(255) NOT NULL,
@@ -30,8 +32,7 @@ CREATE TABLE "cities" (
CONSTRAINT "cities_pkey" PRIMARY KEY ("id") CONSTRAINT "cities_pkey" PRIMARY KEY ("id")
); );
-- CreateTable CREATE TABLE IF NOT EXISTS "comments" (
CREATE TABLE "comments" (
"id" UUID NOT NULL DEFAULT uuid_generate_v4(), "id" UUID NOT NULL DEFAULT uuid_generate_v4(),
"author_id" UUID NOT NULL, "author_id" UUID NOT NULL,
"destination_id" UUID NOT NULL, "destination_id" UUID NOT NULL,
@@ -44,16 +45,14 @@ CREATE TABLE "comments" (
CONSTRAINT "comments_pkey" PRIMARY KEY ("id") CONSTRAINT "comments_pkey" PRIMARY KEY ("id")
); );
-- CreateTable CREATE TABLE IF NOT EXISTS "countries" (
CREATE TABLE "countries" (
"id" UUID NOT NULL DEFAULT uuid_generate_v4(), "id" UUID NOT NULL DEFAULT uuid_generate_v4(),
"name" VARCHAR(255) NOT NULL, "name" VARCHAR(255) NOT NULL,
CONSTRAINT "countries_pkey" PRIMARY KEY ("id") CONSTRAINT "countries_pkey" PRIMARY KEY ("id")
); );
-- CreateTable CREATE TABLE IF NOT EXISTS "messages" (
CREATE TABLE "messages" (
"id" UUID NOT NULL DEFAULT uuid_generate_v4(), "id" UUID NOT NULL DEFAULT uuid_generate_v4(),
"chat_id" UUID NOT NULL, "chat_id" UUID NOT NULL,
"sender_id" UUID NOT NULL, "sender_id" UUID NOT NULL,
@@ -63,8 +62,7 @@ CREATE TABLE "messages" (
CONSTRAINT "messages_pkey" PRIMARY KEY ("id") CONSTRAINT "messages_pkey" PRIMARY KEY ("id")
); );
-- CreateTable CREATE TABLE IF NOT EXISTS "payment_methods" (
CREATE TABLE "payment_methods" (
"id" UUID NOT NULL DEFAULT uuid_generate_v4(), "id" UUID NOT NULL DEFAULT uuid_generate_v4(),
"professional_id" UUID NOT NULL, "professional_id" UUID NOT NULL,
"nequi" BOOLEAN DEFAULT false, "nequi" BOOLEAN DEFAULT false,
@@ -74,8 +72,7 @@ CREATE TABLE "payment_methods" (
CONSTRAINT "payment_methods_pkey" PRIMARY KEY ("id") CONSTRAINT "payment_methods_pkey" PRIMARY KEY ("id")
); );
-- CreateTable CREATE TABLE IF NOT EXISTS "professionals" (
CREATE TABLE "professionals" (
"id" UUID NOT NULL DEFAULT uuid_generate_v4(), "id" UUID NOT NULL DEFAULT uuid_generate_v4(),
"user_id" UUID NOT NULL, "user_id" UUID NOT NULL,
"identification" VARCHAR(50), "identification" VARCHAR(50),
@@ -98,16 +95,14 @@ CREATE TABLE "professionals" (
CONSTRAINT "professionals_pkey" PRIMARY KEY ("id") CONSTRAINT "professionals_pkey" PRIMARY KEY ("id")
); );
-- CreateTable CREATE TABLE IF NOT EXISTS "professions" (
CREATE TABLE "professions" (
"id" UUID NOT NULL DEFAULT uuid_generate_v4(), "id" UUID NOT NULL DEFAULT uuid_generate_v4(),
"name" VARCHAR(255) NOT NULL, "name" VARCHAR(255) NOT NULL,
CONSTRAINT "professions_pkey" PRIMARY KEY ("id") CONSTRAINT "professions_pkey" PRIMARY KEY ("id")
); );
-- CreateTable CREATE TABLE IF NOT EXISTS "regions" (
CREATE TABLE "regions" (
"id" UUID NOT NULL DEFAULT uuid_generate_v4(), "id" UUID NOT NULL DEFAULT uuid_generate_v4(),
"country_id" UUID NOT NULL, "country_id" UUID NOT NULL,
"name" VARCHAR(255) NOT NULL, "name" VARCHAR(255) NOT NULL,
@@ -115,8 +110,7 @@ CREATE TABLE "regions" (
CONSTRAINT "regions_pkey" PRIMARY KEY ("id") CONSTRAINT "regions_pkey" PRIMARY KEY ("id")
); );
-- CreateTable CREATE TABLE IF NOT EXISTS "reputations" (
CREATE TABLE "reputations" (
"user_id" UUID NOT NULL, "user_id" UUID NOT NULL,
"total" INTEGER NOT NULL DEFAULT 0, "total" INTEGER NOT NULL DEFAULT 0,
"average" DECIMAL(3,2) NOT NULL DEFAULT 0, "average" DECIMAL(3,2) NOT NULL DEFAULT 0,
@@ -127,8 +121,7 @@ CREATE TABLE "reputations" (
CONSTRAINT "reputations_pkey" PRIMARY KEY ("user_id") CONSTRAINT "reputations_pkey" PRIMARY KEY ("user_id")
); );
-- CreateTable CREATE TABLE IF NOT EXISTS "schedules" (
CREATE TABLE "schedules" (
"id" UUID NOT NULL DEFAULT uuid_generate_v4(), "id" UUID NOT NULL DEFAULT uuid_generate_v4(),
"professional_id" UUID NOT NULL, "professional_id" UUID NOT NULL,
"day_of_week" SMALLINT NOT NULL, "day_of_week" SMALLINT NOT NULL,
@@ -142,8 +135,7 @@ CREATE TABLE "schedules" (
CONSTRAINT "schedules_pkey" PRIMARY KEY ("id") CONSTRAINT "schedules_pkey" PRIMARY KEY ("id")
); );
-- CreateTable CREATE TABLE IF NOT EXISTS "services" (
CREATE TABLE "services" (
"id" UUID NOT NULL DEFAULT uuid_generate_v4(), "id" UUID NOT NULL DEFAULT uuid_generate_v4(),
"professional_id" UUID NOT NULL, "professional_id" UUID NOT NULL,
"user_id" UUID NOT NULL, "user_id" UUID NOT NULL,
@@ -166,8 +158,7 @@ CREATE TABLE "services" (
CONSTRAINT "services_pkey" PRIMARY KEY ("id") CONSTRAINT "services_pkey" PRIMARY KEY ("id")
); );
-- CreateTable CREATE TABLE IF NOT EXISTS "settings" (
CREATE TABLE "settings" (
"key" VARCHAR(100) NOT NULL, "key" VARCHAR(100) NOT NULL,
"value" JSONB NOT NULL, "value" JSONB NOT NULL,
"updated_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP, "updated_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
@@ -175,8 +166,7 @@ CREATE TABLE "settings" (
CONSTRAINT "settings_pkey" PRIMARY KEY ("key") CONSTRAINT "settings_pkey" PRIMARY KEY ("key")
); );
-- CreateTable CREATE TABLE IF NOT EXISTS "specializations" (
CREATE TABLE "specializations" (
"id" UUID NOT NULL DEFAULT uuid_generate_v4(), "id" UUID NOT NULL DEFAULT uuid_generate_v4(),
"professional_id" UUID NOT NULL, "professional_id" UUID NOT NULL,
"name" VARCHAR(255) NOT NULL, "name" VARCHAR(255) NOT NULL,
@@ -185,8 +175,16 @@ CREATE TABLE "specializations" (
CONSTRAINT "specializations_pkey" PRIMARY KEY ("id") CONSTRAINT "specializations_pkey" PRIMARY KEY ("id")
); );
-- CreateTable CREATE TABLE IF NOT EXISTS "suggestions" (
CREATE TABLE "users" ( "id" UUID NOT NULL DEFAULT uuid_generate_v4(),
"name" VARCHAR(255),
"message" TEXT NOT NULL,
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "suggestions_pkey" PRIMARY KEY ("id")
);
CREATE TABLE IF NOT EXISTS "users" (
"id" UUID NOT NULL DEFAULT uuid_generate_v4(), "id" UUID NOT NULL DEFAULT uuid_generate_v4(),
"email" VARCHAR(255), "email" VARCHAR(255),
"phone" VARCHAR(20), "phone" VARCHAR(20),
@@ -208,126 +206,113 @@ CREATE TABLE "users" (
); );
-- CreateIndex -- CreateIndex
CREATE INDEX "idx_chats_professional_id" ON "chats"("professional_id"); CREATE INDEX IF NOT EXISTS "idx_chats_professional_id" ON "chats"("professional_id");
CREATE INDEX IF NOT EXISTS "idx_chats_user_id" ON "chats"("user_id");
CREATE UNIQUE INDEX IF NOT EXISTS "chats_user_id_professional_id_key" ON "chats"("user_id", "professional_id");
CREATE INDEX IF NOT EXISTS "idx_cities_region_id" ON "cities"("region_id");
CREATE INDEX IF NOT EXISTS "idx_comments_author_id" ON "comments"("author_id");
CREATE INDEX IF NOT EXISTS "idx_comments_destination_id" ON "comments"("destination_id");
CREATE INDEX IF NOT EXISTS "idx_comments_service_id" ON "comments"("service_id");
CREATE UNIQUE INDEX IF NOT EXISTS "countries_name_key" ON "countries"("name");
CREATE INDEX IF NOT EXISTS "idx_messages_chat_id" ON "messages"("chat_id");
CREATE INDEX IF NOT EXISTS "idx_messages_created_at" ON "messages"("created_at");
CREATE UNIQUE INDEX IF NOT EXISTS "payment_methods_professional_id_key" ON "payment_methods"("professional_id");
CREATE UNIQUE INDEX IF NOT EXISTS "professionals_user_id_key" ON "professionals"("user_id");
CREATE INDEX IF NOT EXISTS "idx_professionals_user_id" ON "professionals"("user_id");
CREATE UNIQUE INDEX IF NOT EXISTS "professions_name_key" ON "professions"("name");
CREATE INDEX IF NOT EXISTS "idx_regions_country_id" ON "regions"("country_id");
CREATE INDEX IF NOT EXISTS "idx_schedules_professional_id" ON "schedules"("professional_id");
CREATE UNIQUE INDEX IF NOT EXISTS "schedules_professional_id_day_of_week_key" ON "schedules"("professional_id", "day_of_week");
CREATE INDEX IF NOT EXISTS "idx_services_day" ON "services"("day");
CREATE INDEX IF NOT EXISTS "idx_services_professional_id" ON "services"("professional_id");
CREATE INDEX IF NOT EXISTS "idx_services_status" ON "services"("status");
CREATE INDEX IF NOT EXISTS "idx_services_user_id" ON "services"("user_id");
CREATE INDEX IF NOT EXISTS "idx_specializations_professional_id" ON "specializations"("professional_id");
CREATE UNIQUE INDEX IF NOT EXISTS "users_email_key" ON "users"("email");
CREATE UNIQUE INDEX IF NOT EXISTS "users_phone_key" ON "users"("phone");
-- CreateIndex -- AddForeignKey (idempotent via DO blocks)
CREATE INDEX "idx_chats_user_id" ON "chats"("user_id"); DO $$ BEGIN
ALTER TABLE "chats" ADD CONSTRAINT "chats_professional_id_fkey" FOREIGN KEY ("professional_id") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
-- CreateIndex DO $$ BEGIN
CREATE UNIQUE INDEX "chats_user_id_professional_id_key" ON "chats"("user_id", "professional_id"); ALTER TABLE "chats" ADD CONSTRAINT "chats_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
-- CreateIndex DO $$ BEGIN
CREATE INDEX "idx_cities_region_id" ON "cities"("region_id"); ALTER TABLE "cities" ADD CONSTRAINT "cities_region_id_fkey" FOREIGN KEY ("region_id") REFERENCES "regions"("id") ON DELETE CASCADE ON UPDATE NO ACTION;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
-- CreateIndex DO $$ BEGIN
CREATE INDEX "idx_comments_author_id" ON "comments"("author_id"); ALTER TABLE "comments" ADD CONSTRAINT "comments_author_id_fkey" FOREIGN KEY ("author_id") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
-- CreateIndex DO $$ BEGIN
CREATE INDEX "idx_comments_destination_id" ON "comments"("destination_id"); ALTER TABLE "comments" ADD CONSTRAINT "comments_destination_id_fkey" FOREIGN KEY ("destination_id") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
-- CreateIndex DO $$ BEGIN
CREATE INDEX "idx_comments_service_id" ON "comments"("service_id"); ALTER TABLE "comments" ADD CONSTRAINT "comments_service_id_fkey" FOREIGN KEY ("service_id") REFERENCES "services"("id") ON DELETE NO ACTION ON UPDATE NO ACTION;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
-- CreateIndex DO $$ BEGIN
CREATE UNIQUE INDEX "countries_name_key" ON "countries"("name"); ALTER TABLE "messages" ADD CONSTRAINT "messages_chat_id_fkey" FOREIGN KEY ("chat_id") REFERENCES "chats"("id") ON DELETE CASCADE ON UPDATE NO ACTION;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
-- CreateIndex DO $$ BEGIN
CREATE INDEX "idx_messages_chat_id" ON "messages"("chat_id"); ALTER TABLE "messages" ADD CONSTRAINT "messages_sender_id_fkey" FOREIGN KEY ("sender_id") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
-- CreateIndex DO $$ BEGIN
CREATE INDEX "idx_messages_created_at" ON "messages"("created_at"); ALTER TABLE "payment_methods" ADD CONSTRAINT "payment_methods_professional_id_fkey" FOREIGN KEY ("professional_id") REFERENCES "professionals"("id") ON DELETE CASCADE ON UPDATE NO ACTION;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
-- CreateIndex DO $$ BEGIN
CREATE UNIQUE INDEX "payment_methods_professional_id_key" ON "payment_methods"("professional_id"); ALTER TABLE "professionals" ADD CONSTRAINT "professionals_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
-- CreateIndex DO $$ BEGIN
CREATE UNIQUE INDEX "professionals_user_id_key" ON "professionals"("user_id"); ALTER TABLE "regions" ADD CONSTRAINT "regions_country_id_fkey" FOREIGN KEY ("country_id") REFERENCES "countries"("id") ON DELETE CASCADE ON UPDATE NO ACTION;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
-- CreateIndex DO $$ BEGIN
CREATE INDEX "idx_professionals_user_id" ON "professionals"("user_id"); ALTER TABLE "reputations" ADD CONSTRAINT "reputations_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
-- CreateIndex DO $$ BEGIN
CREATE UNIQUE INDEX "professions_name_key" ON "professions"("name"); ALTER TABLE "schedules" ADD CONSTRAINT "schedules_professional_id_fkey" FOREIGN KEY ("professional_id") REFERENCES "professionals"("id") ON DELETE CASCADE ON UPDATE NO ACTION;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
-- CreateIndex DO $$ BEGIN
CREATE INDEX "idx_regions_country_id" ON "regions"("country_id"); ALTER TABLE "services" ADD CONSTRAINT "services_professional_id_fkey" FOREIGN KEY ("professional_id") REFERENCES "professionals"("id") ON DELETE NO ACTION ON UPDATE NO ACTION;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
-- CreateIndex DO $$ BEGIN
CREATE INDEX "idx_schedules_professional_id" ON "schedules"("professional_id"); ALTER TABLE "services" ADD CONSTRAINT "services_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
-- CreateIndex DO $$ BEGIN
CREATE UNIQUE INDEX "schedules_professional_id_day_of_week_key" ON "schedules"("professional_id", "day_of_week"); ALTER TABLE "specializations" ADD CONSTRAINT "specializations_professional_id_fkey" FOREIGN KEY ("professional_id") REFERENCES "professionals"("id") ON DELETE CASCADE ON UPDATE NO ACTION;
EXCEPTION WHEN duplicate_object THEN NULL;
END $$;
-- CreateIndex -- Trigger: update reputation on comment insert/update
CREATE INDEX "idx_services_day" ON "services"("day");
-- CreateIndex
CREATE INDEX "idx_services_professional_id" ON "services"("professional_id");
-- CreateIndex
CREATE INDEX "idx_services_status" ON "services"("status");
-- CreateIndex
CREATE INDEX "idx_services_user_id" ON "services"("user_id");
-- CreateIndex
CREATE INDEX "idx_specializations_professional_id" ON "specializations"("professional_id");
-- CreateIndex
CREATE UNIQUE INDEX "users_email_key" ON "users"("email");
-- CreateIndex
CREATE UNIQUE INDEX "users_phone_key" ON "users"("phone");
-- AddForeignKey
ALTER TABLE "chats" ADD CONSTRAINT "chats_professional_id_fkey" FOREIGN KEY ("professional_id") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION;
-- AddForeignKey
ALTER TABLE "chats" ADD CONSTRAINT "chats_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION;
-- AddForeignKey
ALTER TABLE "cities" ADD CONSTRAINT "cities_region_id_fkey" FOREIGN KEY ("region_id") REFERENCES "regions"("id") ON DELETE CASCADE ON UPDATE NO ACTION;
-- AddForeignKey
ALTER TABLE "comments" ADD CONSTRAINT "comments_author_id_fkey" FOREIGN KEY ("author_id") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION;
-- AddForeignKey
ALTER TABLE "comments" ADD CONSTRAINT "comments_destination_id_fkey" FOREIGN KEY ("destination_id") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION;
-- AddForeignKey
ALTER TABLE "comments" ADD CONSTRAINT "comments_service_id_fkey" FOREIGN KEY ("service_id") REFERENCES "services"("id") ON DELETE NO ACTION ON UPDATE NO ACTION;
-- AddForeignKey
ALTER TABLE "messages" ADD CONSTRAINT "messages_chat_id_fkey" FOREIGN KEY ("chat_id") REFERENCES "chats"("id") ON DELETE CASCADE ON UPDATE NO ACTION;
-- AddForeignKey
ALTER TABLE "messages" ADD CONSTRAINT "messages_sender_id_fkey" FOREIGN KEY ("sender_id") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION;
-- AddForeignKey
ALTER TABLE "payment_methods" ADD CONSTRAINT "payment_methods_professional_id_fkey" FOREIGN KEY ("professional_id") REFERENCES "professionals"("id") ON DELETE CASCADE ON UPDATE NO ACTION;
-- AddForeignKey
ALTER TABLE "professionals" ADD CONSTRAINT "professionals_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION;
-- AddForeignKey
ALTER TABLE "regions" ADD CONSTRAINT "regions_country_id_fkey" FOREIGN KEY ("country_id") REFERENCES "countries"("id") ON DELETE CASCADE ON UPDATE NO ACTION;
-- AddForeignKey
ALTER TABLE "reputations" ADD CONSTRAINT "reputations_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE NO ACTION;
-- AddForeignKey
ALTER TABLE "schedules" ADD CONSTRAINT "schedules_professional_id_fkey" FOREIGN KEY ("professional_id") REFERENCES "professionals"("id") ON DELETE CASCADE ON UPDATE NO ACTION;
-- AddForeignKey
ALTER TABLE "services" ADD CONSTRAINT "services_professional_id_fkey" FOREIGN KEY ("professional_id") REFERENCES "professionals"("id") ON DELETE NO ACTION ON UPDATE NO ACTION;
-- AddForeignKey
ALTER TABLE "services" ADD CONSTRAINT "services_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION;
-- AddForeignKey
ALTER TABLE "specializations" ADD CONSTRAINT "specializations_professional_id_fkey" FOREIGN KEY ("professional_id") REFERENCES "professionals"("id") ON DELETE CASCADE ON UPDATE NO ACTION;
-- CreateTrigger: update reputation on comment insert/update
CREATE OR REPLACE FUNCTION update_reputation() CREATE OR REPLACE FUNCTION update_reputation()
RETURNS TRIGGER AS $$ RETURNS TRIGGER AS $$
BEGIN BEGIN
@@ -360,4 +345,3 @@ CREATE OR REPLACE TRIGGER trg_update_reputation
AFTER INSERT OR UPDATE ON comments AFTER INSERT OR UPDATE ON comments
FOR EACH ROW FOR EACH ROW
EXECUTE FUNCTION update_reputation(); EXECUTE FUNCTION update_reputation();
@@ -0,0 +1,12 @@
-- CreateTable
CREATE TABLE IF NOT EXISTS "message_logs" (
"id" UUID NOT NULL DEFAULT uuid_generate_v4(),
"channel" VARCHAR(10) NOT NULL,
"recipient" VARCHAR(255) NOT NULL,
"body" TEXT NOT NULL,
"status" VARCHAR(20) NOT NULL,
"error" TEXT,
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "message_logs_pkey" PRIMARY KEY ("id")
);
@@ -0,0 +1,9 @@
-- CreateTable
CREATE TABLE IF NOT EXISTS "suggestions" (
"id" UUID NOT NULL DEFAULT uuid_generate_v4(),
"name" VARCHAR(255),
"message" TEXT NOT NULL,
"created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "suggestions_pkey" PRIMARY KEY ("id")
);
@@ -0,0 +1 @@
ALTER TABLE "users" ADD COLUMN IF NOT EXISTS "is_active" BOOLEAN NOT NULL DEFAULT true;
@@ -0,0 +1 @@
ALTER TABLE "professionals" ADD COLUMN IF NOT EXISTS "slot_duration_minutes" INTEGER NOT NULL DEFAULT 30;
+4 -2
View File
@@ -96,6 +96,7 @@ model professionals {
latitude Decimal? @db.Decimal(10, 7) latitude Decimal? @db.Decimal(10, 7)
longitude Decimal? @db.Decimal(10, 7) longitude Decimal? @db.Decimal(10, 7)
average_score Decimal? @default(0) @db.Decimal(3, 2) average_score Decimal? @default(0) @db.Decimal(3, 2)
slot_duration_minutes Int @default(30)
is_active Boolean? @default(true) is_active Boolean? @default(true)
created_at DateTime @default(now()) @db.Timestamptz(6) created_at DateTime @default(now()) @db.Timestamptz(6)
updated_at DateTime @default(now()) @db.Timestamptz(6) updated_at DateTime @default(now()) @db.Timestamptz(6)
@@ -194,10 +195,10 @@ model suggestions {
model message_logs { model message_logs {
id String @id @default(dbgenerated("uuid_generate_v4()")) @db.Uuid id String @id @default(dbgenerated("uuid_generate_v4()")) @db.Uuid
channel String @db.VarChar(10) // 'sms' | 'email' channel String @db.VarChar(10)
recipient String @db.VarChar(255) recipient String @db.VarChar(255)
body String body String
status String @db.VarChar(20) // 'sent' | 'error' status String @db.VarChar(20)
error String? error String?
created_at DateTime @default(now()) @db.Timestamptz(6) created_at DateTime @default(now()) @db.Timestamptz(6)
} }
@@ -227,6 +228,7 @@ model users {
fcm_token String? fcm_token String?
is_phone_verified Boolean? @default(false) is_phone_verified Boolean? @default(false)
is_email_verified Boolean? @default(false) is_email_verified Boolean? @default(false)
is_active Boolean @default(true)
created_at DateTime @default(now()) @db.Timestamptz(6) created_at DateTime @default(now()) @db.Timestamptz(6)
updated_at DateTime @default(now()) @db.Timestamptz(6) updated_at DateTime @default(now()) @db.Timestamptz(6)
chats_chats_professional_idTousers chats[] @relation("chats_professional_idTousers") chats_chats_professional_idTousers chats[] @relation("chats_professional_idTousers")
+4
View File
@@ -14,6 +14,8 @@ import { StorageModule } from './storage/storage.module';
import { NotificationsModule } from './notifications/notifications.module'; import { NotificationsModule } from './notifications/notifications.module';
import { SmsModule } from './sms/sms.module'; import { SmsModule } from './sms/sms.module';
import { SuggestionsModule } from './suggestions/suggestions.module'; import { SuggestionsModule } from './suggestions/suggestions.module';
import { VerifikModule } from './verifik/verifik.module';
import { MailModule } from './mail/mail.module';
@Module({ @Module({
imports: [ imports: [
@@ -32,6 +34,8 @@ import { SuggestionsModule } from './suggestions/suggestions.module';
NotificationsModule, NotificationsModule,
SmsModule, SmsModule,
SuggestionsModule, SuggestionsModule,
VerifikModule,
MailModule,
], ],
}) })
export class AppModule {} export class AppModule {}
+7
View File
@@ -3,6 +3,7 @@ import { JwtService } from '@nestjs/jwt';
import * as bcrypt from 'bcryptjs'; import * as bcrypt from 'bcryptjs';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { SmsService } from '../sms/sms.service'; import { SmsService } from '../sms/sms.service';
import { MailService } from '../mail/mail.service';
@Injectable() @Injectable()
export class AuthService { export class AuthService {
@@ -10,6 +11,7 @@ export class AuthService {
private prisma: PrismaService, private prisma: PrismaService,
private jwt: JwtService, private jwt: JwtService,
private sms: SmsService, private sms: SmsService,
private mail: MailService,
) {} ) {}
async register(email: string, password: string, name: string) { async register(email: string, password: string, name: string) {
@@ -21,6 +23,7 @@ export class AuthService {
data: { email, password_hash, name }, data: { email, password_hash, name },
}); });
this.mail.sendWelcome(name, email).catch(() => {});
return this.generateToken(user); return this.generateToken(user);
} }
@@ -31,6 +34,8 @@ export class AuthService {
const valid = await bcrypt.compare(password, user.password_hash); const valid = await bcrypt.compare(password, user.password_hash);
if (!valid) throw new UnauthorizedException('Credenciales inválidas'); if (!valid) throw new UnauthorizedException('Credenciales inválidas');
if (user.is_active === false) throw new UnauthorizedException('Tu cuenta ha sido bloqueada');
return this.generateToken(user); return this.generateToken(user);
} }
@@ -48,6 +53,7 @@ export class AuthService {
data: { phone, name: name || phone, is_phone_verified: true }, data: { phone, name: name || phone, is_phone_verified: true },
}); });
} else { } else {
if (user.is_active === false) throw new UnauthorizedException('Tu cuenta ha sido bloqueada');
user = await this.prisma.users.update({ user = await this.prisma.users.update({
where: { id: user.id }, where: { id: user.id },
data: { is_phone_verified: true }, data: { is_phone_verified: true },
@@ -104,6 +110,7 @@ export class AuthService {
}, },
}); });
if (!user) throw new UnauthorizedException('Usuario no encontrado'); if (!user) throw new UnauthorizedException('Usuario no encontrado');
if (user.is_active === false) throw new UnauthorizedException('Tu cuenta ha sido bloqueada');
return { ...user, professional_state: user.pro_state }; return { ...user, professional_state: user.pro_state };
} }
+5 -1
View File
@@ -1,4 +1,4 @@
import { IsEmail, IsString, MinLength, IsOptional, IsPhoneNumber, Matches } from 'class-validator'; import { IsEmail, IsString, MinLength, IsOptional, IsPhoneNumber, Matches, IsBoolean } from 'class-validator';
export class RegisterDto { export class RegisterDto {
@IsEmail() @IsEmail()
@@ -54,6 +54,10 @@ export class UpdateUserDto {
@Matches(/^\d{4}-\d{2}-\d{2}$/, { message: 'birthday must be YYYY-MM-DD' }) @Matches(/^\d{4}-\d{2}-\d{2}$/, { message: 'birthday must be YYYY-MM-DD' })
@IsOptional() @IsOptional()
birthday?: string; birthday?: string;
@IsOptional()
@IsBoolean()
is_active?: boolean;
} }
export class FcmTokenDto { export class FcmTokenDto {
+1
View File
@@ -46,6 +46,7 @@ export class EmailOtpService {
create: { key: 'smtp_config', value: config as any }, create: { key: 'smtp_config', value: config as any },
update: { value: config as any }, update: { value: config as any },
}); });
return { message: 'Configuración SMTP guardada' };
} }
async sendOtp(email: string): Promise<void> { async sendOtp(email: string): Promise<void> {
+11
View File
@@ -0,0 +1,11 @@
import { Module, Global } from '@nestjs/common';
import { MailService } from './mail.service';
import { PrismaModule } from '../prisma/prisma.module';
@Global()
@Module({
imports: [PrismaModule],
providers: [MailService],
exports: [MailService],
})
export class MailModule {}
+248
View File
@@ -0,0 +1,248 @@
import { Injectable, Logger } from '@nestjs/common';
import * as nodemailer from 'nodemailer';
import { PrismaService } from '../prisma/prisma.service';
const BRAND_COLOR = '#42A4EF';
const LOGO = 'https://prosapp.co/img/logo_prosapp.png';
const APP_URL = 'https://app.prosapp.co';
@Injectable()
export class MailService {
private readonly logger = new Logger(MailService.name);
constructor(private prisma: PrismaService) {}
// ── transport ───────────────────────────────────────────────────────────────
private async createTransport(): Promise<{ transport: nodemailer.Transporter; from: string } | null> {
const dbConfig = await this.prisma.settings
.findUnique({ where: { key: 'smtp_config' } })
.then(r => r?.value as any)
.catch(() => null);
const host = dbConfig?.host || process.env.SMTP_HOST;
const port = parseInt(dbConfig?.port || process.env.SMTP_PORT || '587');
const user = dbConfig?.user || process.env.SMTP_USER;
const pass = dbConfig?.pass || process.env.SMTP_PASS;
const from = dbConfig?.from || process.env.EMAIL_FROM || user;
if (!host || !user || !pass) return null;
const transport = nodemailer.createTransport({
host,
port,
secure: port === 465,
auth: { user, pass },
});
return { transport, from: from || user };
}
// Silent send — never throws, logs errors
async tryMail(to: string, subject: string, html: string) {
const ctx = await this.createTransport();
if (!ctx) return; // SMTP not configured — silently skip
try {
await ctx.transport.sendMail({ from: `ProsApp <${ctx.from}>`, to, subject, html });
this.logger.log(`Mail enviado a ${to}: ${subject}`);
await this.prisma.message_logs.create({
data: { channel: 'email', recipient: to, body: subject, status: 'sent' },
}).catch(() => {});
} catch (err) {
this.logger.warn(`Mail falló a ${to}: ${err}`);
await this.prisma.message_logs.create({
data: { channel: 'email', recipient: to, body: subject, status: 'error', error: String(err) },
}).catch(() => {});
}
}
private async adminEmail(): Promise<string | null> {
const g = await this.prisma.settings.findUnique({ where: { key: 'global' } }).then(r => r?.value as any).catch(() => null);
if (g?.admin_email) return g.admin_email;
const smtp = await this.prisma.settings.findUnique({ where: { key: 'smtp_config' } }).then(r => r?.value as any).catch(() => null);
return smtp?.user || null;
}
// ── base template ────────────────────────────────────────────────────────────
private wrap(content: string, preheader = '') {
return `<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>ProsApp</title>
<!--[if mso]><noscript><xml><o:OfficeDocumentSettings><o:PixelsPerInch>96</o:PixelsPerInch></o:OfficeDocumentSettings></xml></noscript><![endif]-->
</head>
<body style="margin:0;padding:0;background:#F0F4F8;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif">
${preheader ? `<div style="display:none;max-height:0;overflow:hidden;color:#F0F4F8">${preheader}</div>` : ''}
<table width="100%" cellpadding="0" cellspacing="0" style="background:#F0F4F8;padding:40px 16px">
<tr><td align="center">
<table width="100%" cellpadding="0" cellspacing="0" style="max-width:560px">
<!-- Header -->
<tr><td style="background:linear-gradient(135deg,${BRAND_COLOR},#1565C0);border-radius:16px 16px 0 0;padding:32px 40px;text-align:center">
<img src="${LOGO}" height="36" alt="ProsApp" style="display:block;margin:0 auto 16px">
<p style="margin:0;color:rgba(255,255,255,0.85);font-size:13px;letter-spacing:0.5px">ProsApp — Profesionales de salud</p>
</td></tr>
<!-- Body -->
<tr><td style="background:#ffffff;padding:40px;border-left:1px solid #E2E8F0;border-right:1px solid #E2E8F0">
${content}
</td></tr>
<!-- Footer -->
<tr><td style="background:#F8FAFC;border:1px solid #E2E8F0;border-top:0;border-radius:0 0 16px 16px;padding:24px 40px;text-align:center">
<p style="margin:0;color:#94A3B8;font-size:12px">© ${new Date().getFullYear()} ProsApp · <a href="${APP_URL}" style="color:${BRAND_COLOR};text-decoration:none">app.prosapp.co</a></p>
<p style="margin:8px 0 0;color:#CBD5E1;font-size:11px">Si no esperabas este correo, puedes ignorarlo.</p>
</td></tr>
</table>
</td></tr>
</table>
</body>
</html>`;
}
private badge(text: string, color: string, bg: string) {
return `<span style="display:inline-block;padding:4px 12px;background:${bg};color:${color};border-radius:20px;font-size:12px;font-weight:600;letter-spacing:0.3px">${text}</span>`;
}
private btn(text: string, href: string) {
return `<a href="${href}" style="display:inline-block;padding:14px 32px;background:${BRAND_COLOR};color:#ffffff;border-radius:10px;font-size:15px;font-weight:600;text-decoration:none;margin-top:24px">${text}</a>`;
}
// ── templates ────────────────────────────────────────────────────────────────
async sendWelcome(name: string, email: string) {
const html = this.wrap(`
<h1 style="margin:0 0 8px;color:#1E293B;font-size:24px">¡Bienvenido a ProsApp, ${name}! 👋</h1>
<p style="margin:0 0 24px;color:#64748B;font-size:15px;line-height:1.6">
Nos alegra tenerte aquí. Ya puedes explorar profesionales de salud, agendar citas y mucho más.
</p>
<table width="100%" cellpadding="0" cellspacing="0" style="background:#F0F8FF;border:1px solid #BAE3FF;border-radius:12px;padding:20px 24px;margin-bottom:24px">
<tr>
<td style="padding:8px 0">
<p style="margin:0;color:#1E293B;font-size:14px">✅ &nbsp;<strong>Cuenta creada</strong> con correo <strong>${email}</strong></p>
</td>
</tr>
<tr>
<td style="padding:8px 0;border-top:1px solid #E0F0FF">
<p style="margin:0;color:#475569;font-size:14px">📋 &nbsp;Completa tu perfil para una mejor experiencia</p>
</td>
</tr>
<tr>
<td style="padding:8px 0;border-top:1px solid #E0F0FF">
<p style="margin:0;color:#475569;font-size:14px">🔍 &nbsp;Encuentra y agenda profesionales de salud</p>
</td>
</tr>
</table>
<div style="text-align:center">${this.btn('Ir a ProsApp', APP_URL)}</div>
`, `Bienvenido a ProsApp, ${name}`);
await this.tryMail(email, `¡Bienvenido a ProsApp, ${name}!`, html);
}
async sendProfessionalSubmitted(profName: string, profEmail: string, profession: string) {
const admin = await this.adminEmail();
if (!admin) return;
const html = this.wrap(`
<p style="margin:0 0 4px;color:#64748B;font-size:13px;text-transform:uppercase;letter-spacing:0.5px">Nueva solicitud pendiente</p>
<h1 style="margin:0 0 24px;color:#1E293B;font-size:22px">Solicitud de profesional recibida</h1>
<table width="100%" cellpadding="0" cellspacing="0" style="border:1px solid #E2E8F0;border-radius:12px;overflow:hidden;margin-bottom:24px">
<tr style="background:#F8FAFC">
<td style="padding:16px 20px;color:#64748B;font-size:13px;font-weight:600;width:40%">Nombre</td>
<td style="padding:16px 20px;color:#1E293B;font-size:14px;font-weight:500">${profName}</td>
</tr>
<tr>
<td style="padding:16px 20px;color:#64748B;font-size:13px;font-weight:600;border-top:1px solid #E2E8F0">Correo</td>
<td style="padding:16px 20px;color:#1E293B;font-size:14px;border-top:1px solid #E2E8F0">${profEmail}</td>
</tr>
<tr style="background:#F8FAFC">
<td style="padding:16px 20px;color:#64748B;font-size:13px;font-weight:600;border-top:1px solid #E2E8F0">Profesión</td>
<td style="padding:16px 20px;color:#1E293B;font-size:14px;font-weight:500;border-top:1px solid #E2E8F0">${profession || '—'}</td>
</tr>
<tr>
<td style="padding:16px 20px;color:#64748B;font-size:13px;font-weight:600;border-top:1px solid #E2E8F0">Estado</td>
<td style="padding:16px 20px;border-top:1px solid #E2E8F0">${this.badge('En revisión', '#92400E', '#FEF3C7')}</td>
</tr>
</table>
<p style="margin:0 0 24px;color:#64748B;font-size:14px;line-height:1.6">
Revisa los documentos adjuntos y aprueba o rechaza la solicitud desde el panel de administración.
</p>
<div style="text-align:center">${this.btn('Revisar en el panel', 'https://admin.prosapp.co/professionals')}</div>
`, `${profName} ha enviado una solicitud de profesional`);
await this.tryMail(admin, `Nueva solicitud de profesional — ${profName}`, html);
}
async sendStatusChanged(
profName: string,
email: string,
status: 'approved' | 'rejected' | 'pending' | 'deactivated',
) {
const configs = {
approved: {
subject: '¡Tu solicitud fue aprobada! 🎉',
badge: this.badge('Aprobado', '#065F46', '#D1FAE5'),
title: '¡Felicidades, ya eres profesional en ProsApp!',
body: 'Tu solicitud fue revisada y <strong>aprobada</strong>. Ya puedes recibir clientes, gestionar tu agenda y ofrecer tus servicios de salud en la plataforma.',
cta: this.btn('Ver mi perfil profesional', APP_URL),
accent: '#10B981',
accentBg: '#D1FAE5',
icon: '✅',
},
rejected: {
subject: 'Actualización sobre tu solicitud de profesional',
badge: this.badge('No aprobada', '#991B1B', '#FEE2E2'),
title: 'Tu solicitud no fue aprobada',
body: 'Lamentablemente tu solicitud <strong>no fue aprobada</strong> en esta ocasión. Puedes corregir los documentos y volver a intentarlo desde la aplicación.',
cta: this.btn('Volver a intentarlo', APP_URL),
accent: '#EF4444',
accentBg: '#FEE2E2',
icon: '📋',
},
pending: {
subject: 'Tu solicitud está en revisión',
badge: this.badge('En revisión', '#92400E', '#FEF3C7'),
title: 'Tu solicitud volvió a revisión',
body: 'Un administrador ha puesto tu solicitud nuevamente <strong>en revisión</strong>. Te notificaremos cuando haya una actualización.',
cta: this.btn('Ver estado', APP_URL),
accent: '#F59E0B',
accentBg: '#FEF3C7',
icon: '🔍',
},
deactivated: {
subject: 'Tu cuenta profesional fue desactivada',
badge: this.badge('Desactivada', '#374151', '#F3F4F6'),
title: 'Tu cuenta profesional fue desactivada',
body: 'Tu perfil profesional ha sido <strong>desactivado</strong>. Si crees que es un error, comunícate con el soporte de ProsApp.',
cta: this.btn('Contactar soporte', APP_URL),
accent: '#6B7280',
accentBg: '#F3F4F6',
icon: '⚠️',
},
};
const c = configs[status];
const html = this.wrap(`
<div style="text-align:center;margin-bottom:28px">
<div style="display:inline-flex;align-items:center;justify-content:center;width:64px;height:64px;background:${c.accentBg};border-radius:50%;font-size:28px;margin-bottom:16px">${c.icon}</div>
<br>${c.badge}
</div>
<h1 style="margin:0 0 12px;color:#1E293B;font-size:22px;text-align:center">${c.title}</h1>
<p style="margin:0 0 28px;color:#64748B;font-size:15px;line-height:1.7;text-align:center">
Hola <strong>${profName}</strong>, ${c.body}
</p>
<div style="background:#F8FAFC;border-left:4px solid ${c.accent};border-radius:0 8px 8px 0;padding:16px 20px;margin-bottom:28px">
<p style="margin:0;color:#475569;font-size:13px;line-height:1.6">
Si tienes preguntas, escríbenos a través del soporte en la app o responde este correo.
</p>
</div>
<div style="text-align:center">${c.cta}</div>
`, c.title);
await this.tryMail(email, c.subject, html);
}
}
@@ -1,4 +1,4 @@
import { IsString, IsOptional, IsArray, IsNumber, IsBoolean, MinLength, Matches } from 'class-validator'; import { IsString, IsOptional, IsArray, IsNumber, IsBoolean, IsInt, Min, Max, MinLength, Matches, IsObject } from 'class-validator';
export class CreateProfessionalDto { export class CreateProfessionalDto {
@IsString() @IsString()
@@ -57,6 +57,14 @@ export class UpdateProfessionalDto {
@IsNumber() @IsNumber()
rate?: number; rate?: number;
@IsOptional()
@IsString()
identification_picture?: string;
@IsOptional()
@IsString()
certificate_picture?: string;
@IsOptional() @IsOptional()
@IsString() @IsString()
banner_picture?: string; banner_picture?: string;
@@ -72,6 +80,20 @@ export class UpdateProfessionalDto {
@IsOptional() @IsOptional()
@IsString() @IsString()
location_preferences?: string; location_preferences?: string;
@IsOptional()
@IsInt()
@Min(5)
@Max(480)
slot_duration_minutes?: number;
@IsOptional()
@IsObject()
payment_methods?: {
nequi?: boolean;
datafono?: boolean;
transferencia?: boolean;
};
} }
export class ScheduleDto { export class ScheduleDto {
@@ -1,4 +1,4 @@
import { Controller, Get, Post, Patch, Param, Body, UseGuards, Req, HttpCode, HttpStatus, NotFoundException } from '@nestjs/common'; import { Controller, Get, Post, Patch, Delete, Param, Body, UseGuards, Req, HttpCode, HttpStatus, NotFoundException } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger'; import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { ProfessionalsService } from './professionals.service'; import { ProfessionalsService } from './professionals.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/jwt-auth.guard';
@@ -15,10 +15,11 @@ export class ProfessionalsController {
@Get() @Get()
findAllActive(@Req() req) { findAllActive(@Req() req) {
const page = +(req.query.page || 1); const search = req.query.search as string | undefined;
const limit = +(req.query.limit || 20);
const city = req.query.city as string | undefined; const city = req.query.city as string | undefined;
return this.pros.findAllActive(page, limit, city); const lat = req.query.lat ? parseFloat(req.query.lat as string) : undefined;
const lng = req.query.lng ? parseFloat(req.query.lng as string) : undefined;
return this.pros.findAllActive(search, city, lat, lng);
} }
@Get('pending') @Get('pending')
@@ -76,6 +77,13 @@ export class ProfessionalsController {
return this.pros.requestProfessional(req.user.sub, dto); return this.pros.requestProfessional(req.user.sub, dto);
} }
@Delete('me')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
async deleteMe(@Req() req) {
return this.pros.resetRejected(req.user.sub);
}
@Patch('me/schedules') @Patch('me/schedules')
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@ApiBearerAuth() @ApiBearerAuth()
@@ -2,9 +2,10 @@ import { Module } from '@nestjs/common';
import { ProfessionalsService } from './professionals.service'; import { ProfessionalsService } from './professionals.service';
import { ProfessionalsController } from './professionals.controller'; import { ProfessionalsController } from './professionals.controller';
import { AuthModule } from '../auth/auth.module'; import { AuthModule } from '../auth/auth.module';
import { NotificationsModule } from '../notifications/notifications.module';
@Module({ @Module({
imports: [AuthModule], imports: [AuthModule, NotificationsModule],
providers: [ProfessionalsService], providers: [ProfessionalsService],
controllers: [ProfessionalsController], controllers: [ProfessionalsController],
exports: [ProfessionalsService], exports: [ProfessionalsService],
@@ -1,29 +1,83 @@
import { Injectable, NotFoundException, BadRequestException, ForbiddenException } from '@nestjs/common'; import { Injectable, NotFoundException, BadRequestException, ForbiddenException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { MailService } from '../mail/mail.service';
import { NotificationsService } from '../notifications/notifications.service';
@Injectable() @Injectable()
export class ProfessionalsService { export class ProfessionalsService {
constructor(private prisma: PrismaService) {} constructor(
private prisma: PrismaService,
private mail: MailService,
private notifications: NotificationsService,
) {}
async findAllActive(page = 1, limit = 20, city?: string) { private async tryPush(userId: string, title: string, body: string) {
const skip = (page - 1) * limit; try {
const u = await this.prisma.users.findUnique({
where: { id: userId }, select: { fcm_token: true },
});
if (u?.fcm_token) await this.notifications.send(u.fcm_token, title, body);
} catch (_) {}
}
async findAllActive(search?: string, city?: string, lat?: number, lng?: number) {
const where: any = { is_active: true }; const where: any = { is_active: true };
if (city) where.users = { city: { contains: city, mode: 'insensitive' } };
const [data, total] = await Promise.all([ if (city && city.trim()) {
this.prisma.professionals.findMany({ where.users = { city: { contains: city.trim(), mode: 'insensitive' } };
}
if (search && search.trim()) {
const words = search.trim().split(/\s+/).filter(w => w.length > 1);
if (words.length > 0) {
where.AND = words.map(word => ({
OR: [
{ profession: { contains: word, mode: 'insensitive' } },
{ users: { name: { contains: word, mode: 'insensitive' } } },
{ users: { city: { contains: word, mode: 'insensitive' } } },
],
}));
}
}
const professionals = await this.prisma.professionals.findMany({
where, where,
skip,
take: limit,
include: { include: {
users: { select: { id: true, name: true, email: true, phone: true, picture: true, city: true } }, users: { select: { id: true, name: true, email: true, phone: true, picture: true, city: true } },
schedules: true, schedules: true,
specializations: true, specializations: true,
payment_methods: true, payment_methods: true,
_count: { select: { services: { where: { status: 'completed' as any } } } },
}, },
}), });
this.prisma.professionals.count({ where }),
]); const haversineKm = (lat1: number, lng1: number, lat2: number, lng2: number) => {
return { data, meta: { total, page, limit, totalPages: Math.ceil(total / limit) || 1 } }; const R = 6371;
const dLat = (lat2 - lat1) * Math.PI / 180;
const dLng = (lng2 - lng1) * Math.PI / 180;
const a = Math.sin(dLat / 2) ** 2 + Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) * Math.sin(dLng / 2) ** 2;
return R * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
};
const sorted = [...professionals]
.sort((a, b) => {
if (lat !== undefined && lng !== undefined) {
const aLat = a.latitude ? Number(a.latitude) : null;
const aLng = a.longitude ? Number(a.longitude) : null;
const bLat = b.latitude ? Number(b.latitude) : null;
const bLng = b.longitude ? Number(b.longitude) : null;
if (aLat && aLng && bLat && bLng) {
const distDiff = haversineKm(lat, lng, aLat, aLng) - haversineKm(lat, lng, bLat, bLng);
if (Math.abs(distDiff) > 0.5) return distDiff;
}
}
const countDiff = (b._count?.services ?? 0) - (a._count?.services ?? 0);
if (countDiff !== 0) return countDiff;
return Number(b.average_score ?? 0) - Number(a.average_score ?? 0);
})
.slice(0, 7);
return { data: sorted, meta: { total: sorted.length, page: 1, limit: 7, totalPages: 1 } };
} }
async findById(id: string) { async findById(id: string) {
@@ -31,7 +85,7 @@ export class ProfessionalsService {
let prof = await this.prisma.professionals.findUnique({ let prof = await this.prisma.professionals.findUnique({
where: { id }, where: { id },
include: { include: {
users: { select: { id: true, name: true, email: true, phone: true, picture: true, city: true } }, users: { select: { id: true, name: true, email: true, phone: true, picture: true, city: true, gender: true } },
schedules: { orderBy: { day_of_week: 'asc' } }, schedules: { orderBy: { day_of_week: 'asc' } },
specializations: true, specializations: true,
payment_methods: true, payment_methods: true,
@@ -41,7 +95,7 @@ export class ProfessionalsService {
prof = await this.prisma.professionals.findUnique({ prof = await this.prisma.professionals.findUnique({
where: { user_id: id }, where: { user_id: id },
include: { include: {
users: { select: { id: true, name: true, email: true, phone: true, picture: true, city: true } }, users: { select: { id: true, name: true, email: true, phone: true, picture: true, city: true, gender: true } },
schedules: { orderBy: { day_of_week: 'asc' } }, schedules: { orderBy: { day_of_week: 'asc' } },
specializations: true, specializations: true,
payment_methods: true, payment_methods: true,
@@ -68,14 +122,44 @@ export class ProfessionalsService {
} }
async upsert(userId: string, data: any) { async upsert(userId: string, data: any) {
const { payment_methods: pm, ...profData } = data;
const buildPm = (forCreate = false) => {
if (!pm) return undefined;
const pmFields = { nequi: pm.nequi ?? false, datafono: pm.datafono ?? false, transferencia: pm.transferencia ?? false };
return forCreate ? { create: pmFields } : { upsert: { update: pmFields, create: pmFields } };
};
const existing = await this.prisma.professionals.findUnique({ where: { user_id: userId } }); const existing = await this.prisma.professionals.findUnique({ where: { user_id: userId } });
if (!existing) { if (!existing) {
await this.prisma.users.update({ where: { id: userId }, data: { pro_state: 1 } }); await this.prisma.users.update({ where: { id: userId }, data: { pro_state: 1 } });
return this.prisma.professionals.create({ data: { user_id: userId, is_active: false, ...data } }); const createData: any = { user_id: userId, is_active: false, ...profData };
if (pm) createData.payment_methods = buildPm(true);
const prof = await this.prisma.professionals.create({ data: createData });
const u = await this.prisma.users.findUnique({ where: { id: userId }, select: { name: true, email: true } });
if (u?.email) this.mail.sendProfessionalSubmitted(u.name || 'Usuario', u.email, data.profession || '').catch(() => {});
this.tryPush(userId, 'Solicitud recibida', 'Hemos recibido tu solicitud profesional. Te avisaremos cuando sea revisada.');
return prof;
} }
return this.prisma.professionals.update({ where: { user_id: userId }, data }); // Re-submitting after rejection reset (pro_state=0): move back to pending
const user = await this.prisma.users.findUnique({ where: { id: userId }, select: { pro_state: true, name: true, email: true } });
if (user?.pro_state === 0) {
const resubData: any = { ...profData, is_active: false };
if (pm) resubData.payment_methods = buildPm();
await this.prisma.$transaction([
this.prisma.professionals.update({ where: { user_id: userId }, data: resubData }),
this.prisma.users.update({ where: { id: userId }, data: { pro_state: 1 } }),
]);
if (user.email) this.mail.sendProfessionalSubmitted(user.name || 'Usuario', user.email, data.profession || '').catch(() => {});
this.tryPush(userId, 'Solicitud recibida', 'Hemos recibido tu solicitud profesional. Te avisaremos cuando sea revisada.');
return this.prisma.professionals.findUnique({ where: { user_id: userId } });
}
const updateData: any = { ...profData };
if (pm) updateData.payment_methods = buildPm();
return this.prisma.professionals.update({ where: { user_id: userId }, data: updateData });
} }
async updateSchedules(professionalId: string, schedules: any[]) { async updateSchedules(professionalId: string, schedules: any[]) {
@@ -145,15 +229,12 @@ export class ProfessionalsService {
if (!prof) throw new NotFoundException('Profesional no encontrado'); if (!prof) throw new NotFoundException('Profesional no encontrado');
await this.prisma.$transaction([ await this.prisma.$transaction([
this.prisma.professionals.update({ this.prisma.professionals.update({ where: { id: professionalId }, data: { is_active: true } }),
where: { id: professionalId }, this.prisma.users.update({ where: { id: prof.user_id }, data: { pro_state: 2 } }),
data: { is_active: true },
}),
this.prisma.users.update({
where: { id: prof.user_id },
data: { pro_state: 2 },
}),
]); ]);
const u = await this.prisma.users.findUnique({ where: { id: prof.user_id }, select: { name: true, email: true } });
if (u?.email) this.mail.sendStatusChanged(u.name || 'Profesional', u.email, 'approved').catch(() => {});
this.tryPush(prof.user_id, '¡Solicitud aprobada! 🎉', 'Tu perfil profesional ha sido aprobado en ProsApp.');
return { message: 'Profesional aprobado' }; return { message: 'Profesional aprobado' };
} }
@@ -162,12 +243,21 @@ export class ProfessionalsService {
if (!prof) throw new NotFoundException('Profesional no encontrado'); if (!prof) throw new NotFoundException('Profesional no encontrado');
await this.prisma.$transaction([ await this.prisma.$transaction([
this.prisma.professionals.update({ where: { id: professionalId }, data: { is_active: false } }), this.prisma.professionals.update({ where: { id: professionalId }, data: { is_active: false, updated_at: new Date() } }),
this.prisma.users.update({ where: { id: prof.user_id }, data: { pro_state: 3 } }), this.prisma.users.update({ where: { id: prof.user_id }, data: { pro_state: 3 } }),
]); ]);
const u = await this.prisma.users.findUnique({ where: { id: prof.user_id }, select: { name: true, email: true } });
if (u?.email) this.mail.sendStatusChanged(u.name || 'Profesional', u.email, 'rejected').catch(() => {});
this.tryPush(prof.user_id, 'Actualización de tu solicitud', 'Tu solicitud profesional fue rechazada. Puedes volver a intentarlo.');
return { message: 'Solicitud rechazada' }; return { message: 'Solicitud rechazada' };
} }
async resetRejected(userId: string) {
// Always reset pro_state regardless of whether a professional record exists
await this.prisma.users.update({ where: { id: userId }, data: { pro_state: 0 } });
return { message: 'Solicitud reiniciada' };
}
async deactivate(id: string) { async deactivate(id: string) {
const prof = await this.prisma.professionals.findUnique({ where: { id } }); const prof = await this.prisma.professionals.findUnique({ where: { id } });
if (!prof) throw new NotFoundException('Profesional no encontrado'); if (!prof) throw new NotFoundException('Profesional no encontrado');
@@ -175,6 +265,9 @@ export class ProfessionalsService {
this.prisma.professionals.update({ where: { id }, data: { is_active: false } }), this.prisma.professionals.update({ where: { id }, data: { is_active: false } }),
this.prisma.users.update({ where: { id: prof.user_id }, data: { pro_state: 3 } }), this.prisma.users.update({ where: { id: prof.user_id }, data: { pro_state: 3 } }),
]); ]);
const u = await this.prisma.users.findUnique({ where: { id: prof.user_id }, select: { name: true, email: true } });
if (u?.email) this.mail.sendStatusChanged(u.name || 'Profesional', u.email, 'deactivated').catch(() => {});
this.tryPush(prof.user_id, 'Cuenta desactivada', 'Tu cuenta profesional ha sido desactivada.');
return { message: 'Profesional desactivado' }; return { message: 'Profesional desactivado' };
} }
@@ -185,6 +278,9 @@ export class ProfessionalsService {
this.prisma.professionals.update({ where: { id }, data: { is_active: false } }), this.prisma.professionals.update({ where: { id }, data: { is_active: false } }),
this.prisma.users.update({ where: { id: prof.user_id }, data: { pro_state: 1 } }), this.prisma.users.update({ where: { id: prof.user_id }, data: { pro_state: 1 } }),
]); ]);
const u = await this.prisma.users.findUnique({ where: { id: prof.user_id }, select: { name: true, email: true } });
if (u?.email) this.mail.sendStatusChanged(u.name || 'Profesional', u.email, 'pending').catch(() => {});
this.tryPush(prof.user_id, 'Solicitud en revisión', 'Tu solicitud está siendo revisada por el equipo de ProsApp.');
return { message: 'Profesional puesto en revisión' }; return { message: 'Profesional puesto en revisión' };
} }
+8
View File
@@ -64,3 +64,11 @@ export class UpdateServiceStatusDto {
@IsEnum(ServiceStatus) @IsEnum(ServiceStatus)
status: ServiceStatus; status: ServiceStatus;
} }
export class BlockSlotDto {
@IsDateString()
day: string;
@Matches(/^\d{2}:\d{2}$/)
range1_hour1: string;
}
+22 -2
View File
@@ -1,8 +1,8 @@
import { Controller, Get, Post, Patch, Param, Body, UseGuards, Req, Query } from '@nestjs/common'; import { Controller, Get, Post, Patch, Param, Body, UseGuards, Req, Query, ParseIntPipe, Optional } from '@nestjs/common';
import { ApiTags, ApiBearerAuth, ApiQuery } from '@nestjs/swagger'; import { ApiTags, ApiBearerAuth, ApiQuery } from '@nestjs/swagger';
import { ServicesService } from './services.service'; import { ServicesService } from './services.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CreateServiceDto, UpdateServiceStatusDto } from './dto/service.dto'; import { CreateServiceDto, UpdateServiceStatusDto, BlockSlotDto } from './dto/service.dto';
@ApiTags('Services') @ApiTags('Services')
@Controller('services') @Controller('services')
@@ -16,6 +16,13 @@ export class ServicesController {
return this.services.create({ ...dto, user_id: req.user.sub }); return this.services.create({ ...dto, user_id: req.user.sub });
} }
@Post('block')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
blockSlot(@Req() req, @Body() dto: BlockSlotDto) {
return this.services.blockSlot(req.user.sub, dto);
}
@Get() @Get()
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@ApiBearerAuth() @ApiBearerAuth()
@@ -94,6 +101,19 @@ export class ServicesController {
return this.services.getPublicCalendar(id); return this.services.getPublicCalendar(id);
} }
@Get('admin/professional/:professionalId')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiQuery({ name: 'page', required: false })
@ApiQuery({ name: 'limit', required: false })
getByProfessionalAdmin(
@Param('professionalId') professionalId: string,
@Query('page') page?: string,
@Query('limit') limit?: string,
) {
return this.services.getServicesByProfessionalAdmin(professionalId, +(page || 1), +(limit || 50));
}
@Get(':id') @Get(':id')
@UseGuards(JwtAuthGuard) @UseGuards(JwtAuthGuard)
@ApiBearerAuth() @ApiBearerAuth()
+106 -3
View File
@@ -36,7 +36,51 @@ export class ServicesService {
if (!prof) prof = await this.prisma.professionals.findUnique({ where: { user_id: data.professional_id } }); if (!prof) prof = await this.prisma.professionals.findUnique({ where: { user_id: data.professional_id } });
if (!prof || !prof.is_active) throw new BadRequestException('Profesional no disponible'); if (!prof || !prof.is_active) throw new BadRequestException('Profesional no disponible');
return this.prisma.services.create({ if (data.range1_hour1) {
const slotTime = new Date(`1970-01-01T${data.range1_hour1}:00`);
// Double-booking check: reject if this slot already has a non-cancelled/denied service
const conflict = await this.prisma.services.findFirst({
where: {
professional_id: prof.id,
day: new Date(data.day),
range1_hour1: slotTime,
status: { notIn: ['denied', 'cancelled'] },
},
});
if (conflict) throw new BadRequestException('Este horario ya está ocupado');
// Schedule validation: confirm the slot is within the professional's configured hours
// JS getDay() → 0=Sun…6=Sat; DB convention → 0=Mon…6=Sun, so: (getDay()+6)%7
const jsDay = new Date(data.day).getDay();
const dayOfWeek = (jsDay + 6) % 7;
const schedule = await this.prisma.schedules.findFirst({
where: { professional_id: prof.id, day_of_week: dayOfWeek, enabled: true },
});
if (!schedule) throw new BadRequestException('El profesional no atiende ese día');
const toMins = (d: Date | null) => d ? d.getUTCHours() * 60 + d.getUTCMinutes() : null;
const [sh, sm] = data.range1_hour1.split(':').map(Number);
const reqMins = sh * 60 + sm;
let inRange = false;
if (schedule.continuous_day) {
// Full day: range1_hour1 → range2_hour2
const start = toMins(schedule.range1_hour1);
const end = toMins(schedule.range2_hour2);
if (start !== null && end !== null) inRange = reqMins >= start && reqMins < end;
} else {
// Morning block: range1_hour1 → range1_hour2
const s1 = toMins(schedule.range1_hour1), e1 = toMins(schedule.range1_hour2);
if (s1 !== null && e1 !== null) inRange = inRange || (reqMins >= s1 && reqMins < e1);
// Afternoon block: range2_hour1 → range2_hour2
const s2 = toMins(schedule.range2_hour1), e2 = toMins(schedule.range2_hour2);
if (s2 !== null && e2 !== null) inRange = inRange || (reqMins >= s2 && reqMins < e2);
}
if (!inRange) throw new BadRequestException('La hora seleccionada está fuera del horario del profesional');
}
const created = await this.prisma.services.create({
data: { data: {
professional_id: prof.id, professional_id: prof.id,
user_id: data.user_id, user_id: data.user_id,
@@ -51,6 +95,21 @@ export class ServicesService {
location_preference: data.location_preference as any, location_preference: data.location_preference as any,
}, },
}); });
// Notify the professional of the new service request
const profUser = await this.prisma.users.findUnique({
where: { id: prof.user_id },
select: { fcm_token: true },
});
if (profUser?.fcm_token) {
this.notifications.send(
profUser.fcm_token,
'Nueva solicitud de servicio',
'Tienes una nueva solicitud pendiente de un cliente.',
).catch(() => {});
}
return created;
} }
async updateStatus(serviceId: string, newStatus: ServiceStatus, userId: string) { async updateStatus(serviceId: string, newStatus: ServiceStatus, userId: string) {
@@ -106,6 +165,32 @@ export class ServicesService {
return updated; return updated;
} }
async blockSlot(userId: string, data: { day: string; range1_hour1: string }) {
const prof = await this.prisma.professionals.findUnique({ where: { user_id: userId } });
if (!prof) throw new NotFoundException('No eres un profesional');
const slotTime = new Date(`1970-01-01T${data.range1_hour1}:00`);
const conflict = await this.prisma.services.findFirst({
where: {
professional_id: prof.id,
day: new Date(data.day),
range1_hour1: slotTime,
status: { notIn: ['denied', 'cancelled'] },
},
});
if (conflict) throw new BadRequestException('Este horario ya está ocupado o bloqueado');
return this.prisma.services.create({
data: {
professional_id: prof.id,
user_id: prof.user_id,
day: new Date(data.day),
range1_hour1: slotTime,
status: 'self_booked' as any,
},
});
}
async findByUser(userId: string, page = 1, limit = 20) { async findByUser(userId: string, page = 1, limit = 20) {
const skip = (page - 1) * limit; const skip = (page - 1) * limit;
const where = { user_id: userId, status: { notIn: ['completed' as const, 'cancelled' as const, 'denied' as const] } }; const where = { user_id: userId, status: { notIn: ['completed' as const, 'cancelled' as const, 'denied' as const] } };
@@ -125,7 +210,7 @@ export class ServicesService {
async findByProfessional(userId: string, page = 1, limit = 20) { async findByProfessional(userId: string, page = 1, limit = 20) {
const prof = await this.prisma.professionals.findUnique({ where: { user_id: userId } }); const prof = await this.prisma.professionals.findUnique({ where: { user_id: userId } });
if (!prof) throw new NotFoundException('No eres un profesional'); if (!prof) throw new NotFoundException('No eres un profesional');
const where = { professional_id: prof.id }; const where = { professional_id: prof.id, status: { notIn: ['self_booked', 'denied', 'cancelled'] as any[] } };
const skip = (page - 1) * limit; const skip = (page - 1) * limit;
const [data, total] = await Promise.all([ const [data, total] = await Promise.all([
this.prisma.services.findMany({ this.prisma.services.findMany({
@@ -162,8 +247,10 @@ export class ServicesService {
const service = await this.prisma.services.findUnique({ const service = await this.prisma.services.findUnique({
where: { id }, where: { id },
include: { include: {
// Patient data (for professional viewing the service)
users: { select: { id: true, name: true, picture: true, phone: true } }, users: { select: { id: true, name: true, picture: true, phone: true } },
professionals: { include: { users: { select: { name: true, picture: true } } } }, // Professional data (for patient viewing the service — needs id and phone for chat/call)
professionals: { include: { users: { select: { id: true, name: true, picture: true, phone: true } } } },
}, },
}); });
if (!service) throw new NotFoundException('Servicio no encontrado'); if (!service) throw new NotFoundException('Servicio no encontrado');
@@ -226,6 +313,22 @@ export class ServicesService {
return { schedules, services }; return { schedules, services };
} }
async getServicesByProfessionalAdmin(professionalId: string, page = 1, limit = 50) {
const skip = (page - 1) * limit;
const where = { professional_id: professionalId };
const [data, total] = await Promise.all([
this.prisma.services.findMany({
where,
skip,
take: limit,
include: { users: { select: { id: true, name: true, picture: true, phone: true } } },
orderBy: { day: 'desc' },
}),
this.prisma.services.count({ where }),
]);
return { data, meta: { total, page, limit, totalPages: Math.ceil(total / limit) || 1 } };
}
async findAll(page = 1, limit = 20) { async findAll(page = 1, limit = 20) {
const skip = (page - 1) * limit; const skip = (page - 1) * limit;
const [data, total] = await Promise.all([ const [data, total] = await Promise.all([
+18 -2
View File
@@ -1,14 +1,15 @@
import { Controller, Get, Patch, Body, UseGuards, Param, Res, Query } from '@nestjs/common'; import { Controller, Get, Post, Patch, Body, UseGuards, Param, Res, Query } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger'; import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { Response } from 'express'; import { Response } from 'express';
import { SettingsService } from './settings.service'; import { SettingsService } from './settings.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { EmailOtpService } from '../auth/email-otp.service'; import { EmailOtpService } from '../auth/email-otp.service';
import { MailService } from '../mail/mail.service';
@ApiTags('Settings') @ApiTags('Settings')
@Controller('settings') @Controller('settings')
export class SettingsController { export class SettingsController {
constructor(private settings: SettingsService, private emailOtp: EmailOtpService) {} constructor(private settings: SettingsService, private emailOtp: EmailOtpService, private mail: MailService) {}
@Get() @Get()
getGlobal() { return this.settings.getGlobal(); } getGlobal() { return this.settings.getGlobal(); }
@@ -44,6 +45,21 @@ export class SettingsController {
return this.emailOtp.saveSmtpConfig(body); return this.emailOtp.saveSmtpConfig(body);
} }
@Post('test-email')
@UseGuards(JwtAuthGuard) @ApiBearerAuth()
async testEmail(@Body() body: { to: string }) {
await this.mail.tryMail(
body.to,
'Correo de prueba — ProsApp',
`<div style="font-family:sans-serif;padding:32px;max-width:480px;margin:auto">
<h2 style="color:#1e293b">✅ Configuración SMTP correcta</h2>
<p style="color:#64748b">Si recibes este correo, el servidor SMTP está funcionando correctamente en ProsApp.</p>
<p style="color:#94a3b8;font-size:12px">Enviado desde el panel de administración.</p>
</div>`,
);
return { message: `Correo de prueba enviado a ${body.to}` };
}
// Message logs // Message logs
@Get('message-logs') @Get('message-logs')
@UseGuards(JwtAuthGuard) @ApiBearerAuth() @UseGuards(JwtAuthGuard) @ApiBearerAuth()
+8 -1
View File
@@ -1,4 +1,4 @@
import { Controller, Get, Patch, Param, Body, UseGuards, Req } from '@nestjs/common'; import { Controller, Get, Patch, Delete, Param, Body, UseGuards, Req } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger'; import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { UsersService } from './users.service'; import { UsersService } from './users.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { JwtAuthGuard } from '../auth/jwt-auth.guard';
@@ -48,4 +48,11 @@ export class UsersController {
findById(@Param('id') id: string) { findById(@Param('id') id: string) {
return this.users.findById(id); return this.users.findById(id);
} }
@Delete(':id')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
deleteById(@Param('id') id: string) {
return this.users.deleteById(id);
}
} }
+4
View File
@@ -31,4 +31,8 @@ export class UsersService {
updateFcmToken(id: string, fcm_token: string) { updateFcmToken(id: string, fcm_token: string) {
return this.prisma.users.update({ where: { id }, data: { fcm_token } }); return this.prisma.users.update({ where: { id }, data: { fcm_token } });
} }
deleteById(id: string) {
return this.prisma.users.delete({ where: { id } });
}
} }
+43
View File
@@ -0,0 +1,43 @@
import { Controller, Post, Get, Body, Param, Query, UseGuards } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { VerifikService } from './verifik.service';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
@ApiTags('Verifik')
@Controller('verifik')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
export class VerifikController {
constructor(private verifik: VerifikService) {}
@Get('status')
status() { return this.verifik.getStatus(); }
@Post('send-otp')
sendOtp(@Body() body: { email: string }) {
return this.verifik.sendOtp(body.email);
}
@Post('confirm')
confirm(@Body() body: { email: string; otp: string }) {
return this.verifik.confirmOtp(body.email, body.otp);
}
@Post('refresh')
refresh() { return this.verifik.refreshToken(); }
// Query by professional ID — looks up cedula from DB
@Get('rethus/professional/:id')
rethusByProfessional(@Param('id') id: string) {
return this.verifik.queryRethusByProfessional(id);
}
// Direct query by document
@Get('rethus')
rethus(
@Query('documentType') documentType: string,
@Query('documentNumber') documentNumber: string,
) {
return this.verifik.queryRethus(documentType, documentNumber);
}
}
+13
View File
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { VerifikService } from './verifik.service';
import { VerifikController } from './verifik.controller';
import { PrismaModule } from '../prisma/prisma.module';
import { AuthModule } from '../auth/auth.module';
@Module({
imports: [PrismaModule, AuthModule],
providers: [VerifikService],
controllers: [VerifikController],
exports: [VerifikService],
})
export class VerifikModule {}
+111
View File
@@ -0,0 +1,111 @@
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
const BASE = 'https://api.verifik.co';
@Injectable()
export class VerifikService {
private readonly logger = new Logger(VerifikService.name);
constructor(private prisma: PrismaService) {}
// ── token storage ──────────────────────────────────────────────────────────
private async getStoredToken(): Promise<string | null> {
const s = await this.prisma.settings.findUnique({ where: { key: 'verifik_config' } });
return (s?.value as any)?.token ?? null;
}
private async saveToken(token: string) {
await this.prisma.settings.upsert({
where: { key: 'verifik_config' },
create: { key: 'verifik_config', value: { token } },
update: { value: { token } },
});
}
// ── auth flow ──────────────────────────────────────────────────────────────
async sendOtp(email: string) {
const res = await fetch(`${BASE}/v2/projects/email-login?email=${encodeURIComponent(email)}`, {
method: 'POST',
headers: { Accept: 'application/json' },
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error((body as any).message || `Error ${res.status}`);
}
return { message: 'OTP enviado al correo' };
}
async confirmOtp(email: string, otp: string) {
const res = await fetch(`${BASE}/v2/projects/email-login/confirm`, {
method: 'POST',
headers: { Accept: 'application/json', 'Content-Type': 'application/json' },
body: JSON.stringify({ email, otp }),
});
const body = await res.json().catch(() => ({})) as any;
if (!res.ok) throw new Error(body.message || `Error ${res.status}`);
const token: string = body.data?.accessToken ?? body.accessToken;
if (!token) throw new Error('No se recibió token');
await this.saveToken(token);
return { message: 'Conectado correctamente' };
}
async refreshToken() {
const token = await this.getStoredToken();
if (!token) throw new Error('No hay token guardado');
const res = await fetch(`${BASE}/v2/auth/session?origin=refresh&expiresIn=1`, {
headers: { Accept: 'application/json', Authorization: token },
});
const body = await res.json().catch(() => ({})) as any;
if (!res.ok) throw new Error(body.message || `Error ${res.status}`);
const newToken: string = body.accessToken ?? body.data?.accessToken;
if (!newToken) throw new Error('No se recibió token renovado');
await this.saveToken(newToken);
return { message: 'Token renovado' };
}
async getStatus() {
const token = await this.getStoredToken();
if (!token) return { connected: false };
// Quick validation — session endpoint with no origin param just validates
const res = await fetch(`${BASE}/v2/auth/session`, {
headers: { Accept: 'application/json', Authorization: token },
});
return { connected: res.ok };
}
// ── RETHUS lookup ──────────────────────────────────────────────────────────
async queryRethusByProfessional(professionalId: string) {
const prof = await this.prisma.professionals.findUnique({ where: { id: professionalId } });
if (!prof) throw new Error('Profesional no encontrado');
if (!prof.identification) throw new Error('El profesional no tiene número de cédula registrado');
return this.queryRethus('CC', prof.identification);
}
async queryRethus(documentType: string, documentNumber: string) {
let token = await this.getStoredToken();
if (!token) throw new Error('Verifik no está configurado. Conecta tu cuenta en Configuración.');
const call = async (t: string) =>
fetch(`${BASE}/v2/co/cedula/rethus?documentType=${documentType}&documentNumber=${documentNumber}`, {
headers: { Accept: 'application/json', Authorization: `Bearer ${t}` },
});
let res = await call(token);
// Auto-refresh on 401
if (res.status === 401) {
this.logger.log('Token Verifik expirado, renovando…');
await this.refreshToken();
token = await this.getStoredToken();
res = await call(token!);
}
const body = await res.json().catch(() => ({})) as any;
if (!res.ok) throw new Error(body.message || `Error Verifik ${res.status}`);
return body.data ?? body;
}
}
+1
View File
@@ -6,6 +6,7 @@ services:
environment: environment:
- PORT=3001 - PORT=3001
- STORAGE_URL=https://backend.prosapp.co/uploads - STORAGE_URL=https://backend.prosapp.co/uploads
- DATABASE_URL=postgresql://prosapp_user:ProsappPass123!@wkuvmdsy39relyhnugk87eqb:5432/prosapp
volumes: volumes:
- uploads_data:/app/uploads - uploads_data:/app/uploads