Initial commit: SKEEN Derma Experts - Sistema Integral de Gestión Clínica

- Frontend React (SKEEN Brand) con Vite, TypeScript, Tailwind
- Frontend Homenest (versión alternativa)
- Módulos Odoo 17 custom (citas, pacientes, monedero, pagos, ventas, inventario, whatsapp)
- WACRM fork (Next.js 16 + Supabase)
- Hermes + Bridge + Skills (Qwen3.6 via Nan Builders)
- Scripts de migración y operación
- Documentación extensiva en docs/
This commit is contained in:
2026-07-20 07:44:23 +00:00
commit a718592291
699 changed files with 324602 additions and 0 deletions

View File

@@ -0,0 +1,146 @@
// ============================================================
// GET /api/v1/webhooks/{id} — read an endpoint (webhooks:manage)
// PATCH /api/v1/webhooks/{id} — update url/events/is_active
// DELETE /api/v1/webhooks/{id} — remove an endpoint
//
// All account-scoped: a foreign id → 404 (never 403). The signing
// secret is never returned here — it's shown once at creation only.
// ============================================================
import { requireApiKey } from '@/lib/auth/api-context';
import { ok, fail, toApiErrorResponse } from '@/lib/api/v1/respond';
import { normalizeEvents } from '@/lib/webhooks/events';
import {
WEBHOOK_PUBLIC_COLUMNS,
serializeWebhookEndpoint,
normalizeWebhookUrl,
} from '@/lib/webhooks/endpoints';
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const ctx = await requireApiKey(request, 'webhooks:manage');
const { id } = await params;
const { data, error } = await ctx.supabase
.from('webhook_endpoints')
.select(WEBHOOK_PUBLIC_COLUMNS)
.eq('id', id)
.eq('account_id', ctx.accountId)
.maybeSingle();
if (error) {
console.error('[api/v1/webhooks] read error:', error);
return fail('internal', 'Failed to read webhook', 500);
}
if (!data) return fail('not_found', 'Webhook not found', 404);
return ok(serializeWebhookEndpoint(data as Record<string, unknown>));
} catch (err) {
return toApiErrorResponse(err);
}
}
export async function PATCH(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const ctx = await requireApiKey(request, 'webhooks:manage');
const { id } = await params;
const body = (await request.json().catch(() => null)) as Record<
string,
unknown
> | null;
if (!body || typeof body !== 'object') {
return fail('bad_request', 'Request body must be a JSON object', 400);
}
const updates: Record<string, unknown> = {};
if ('url' in body) {
const url = normalizeWebhookUrl(body.url);
if (!url) {
return fail('bad_request', "'url' must be a valid https:// URL", 400);
}
updates.url = url;
}
if ('events' in body) {
const events = normalizeEvents(body.events);
if (!events) {
return fail(
'bad_request',
"'events' must be a non-empty array of known event names",
400
);
}
updates.events = events;
}
if ('is_active' in body) {
if (typeof body.is_active !== 'boolean') {
return fail('bad_request', "'is_active' must be a boolean", 400);
}
updates.is_active = body.is_active;
// Re-enabling a disabled endpoint clears its failure streak so it
// isn't instantly re-disabled by a single stale failure.
if (body.is_active === true) updates.failure_count = 0;
}
if (Object.keys(updates).length === 0) {
return fail('bad_request', 'No updatable fields provided', 400);
}
// Scope the update by account_id so a foreign id touches nothing;
// the returned row (null when unmatched) drives the 404.
const { data, error } = await ctx.supabase
.from('webhook_endpoints')
.update(updates)
.eq('id', id)
.eq('account_id', ctx.accountId)
.select(WEBHOOK_PUBLIC_COLUMNS)
.maybeSingle();
if (error) {
console.error('[api/v1/webhooks] update error:', error);
return fail('internal', 'Failed to update webhook', 500);
}
if (!data) return fail('not_found', 'Webhook not found', 404);
return ok(serializeWebhookEndpoint(data as Record<string, unknown>));
} catch (err) {
return toApiErrorResponse(err);
}
}
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const ctx = await requireApiKey(request, 'webhooks:manage');
const { id } = await params;
const { data, error } = await ctx.supabase
.from('webhook_endpoints')
.delete()
.eq('id', id)
.eq('account_id', ctx.accountId)
.select('id')
.maybeSingle();
if (error) {
console.error('[api/v1/webhooks] delete error:', error);
return fail('internal', 'Failed to delete webhook', 500);
}
if (!data) return fail('not_found', 'Webhook not found', 404);
return ok({ id: data.id, deleted: true });
} catch (err) {
return toApiErrorResponse(err);
}
}

View File

@@ -0,0 +1,102 @@
// ============================================================
// GET /api/v1/webhooks — list webhook endpoints (scope: webhooks:manage)
// POST /api/v1/webhooks — register an endpoint (scope: webhooks:manage)
//
// POST returns the signing `secret` in plaintext exactly once — store
// it to verify the `X-Wacrm-Signature` on deliveries. wacrm keeps only
// an encrypted copy and can never show it again.
// ============================================================
import { requireApiKey } from '@/lib/auth/api-context';
import { ok, okList, fail, toApiErrorResponse } from '@/lib/api/v1/respond';
import { encrypt } from '@/lib/whatsapp/encryption';
import { normalizeEvents } from '@/lib/webhooks/events';
import {
WEBHOOK_PUBLIC_COLUMNS,
serializeWebhookEndpoint,
generateWebhookSecret,
normalizeWebhookUrl,
} from '@/lib/webhooks/endpoints';
export async function GET(request: Request) {
try {
const ctx = await requireApiKey(request, 'webhooks:manage');
const { data, error } = await ctx.supabase
.from('webhook_endpoints')
.select(WEBHOOK_PUBLIC_COLUMNS)
.eq('account_id', ctx.accountId)
.order('created_at', { ascending: false });
if (error) {
console.error('[api/v1/webhooks] list error:', error);
return fail('internal', 'Failed to list webhooks', 500);
}
// The roster is small and settings-class — return it whole (the
// list envelope's cursor is always null here).
return okList(
(data ?? []).map((r) =>
serializeWebhookEndpoint(r as Record<string, unknown>)
),
null
);
} catch (err) {
return toApiErrorResponse(err);
}
}
export async function POST(request: Request) {
try {
const ctx = await requireApiKey(request, 'webhooks:manage');
const body = (await request.json().catch(() => null)) as Record<
string,
unknown
> | null;
if (!body || typeof body !== 'object') {
return fail('bad_request', 'Request body must be a JSON object', 400);
}
const url = normalizeWebhookUrl(body.url);
if (!url) {
return fail('bad_request', "'url' must be a valid https:// URL", 400);
}
const events = normalizeEvents(body.events);
if (!events) {
return fail(
'bad_request',
"'events' must be a non-empty array of known event names",
400
);
}
const secret = generateWebhookSecret();
const { data: created, error } = await ctx.supabase
.from('webhook_endpoints')
.insert({
account_id: ctx.accountId,
created_by: ctx.createdBy,
url,
secret: encrypt(secret),
events,
})
.select(WEBHOOK_PUBLIC_COLUMNS)
.single();
if (error || !created) {
console.error('[api/v1/webhooks] create error:', error);
return fail('internal', 'Failed to create webhook', 500);
}
// Secret shown exactly once.
return ok(
{ ...serializeWebhookEndpoint(created as Record<string, unknown>), secret },
201
);
} catch (err) {
return toApiErrorResponse(err);
}
}