refactor: code review improvements
This commit is contained in:
parent
b40333a2cd
commit
79520fe28f
32 changed files with 3146 additions and 799 deletions
36
README.md
36
README.md
|
|
@ -41,29 +41,29 @@ docker compose up -d
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
| Layer | Technology |
|
| Layer | Technology |
|
||||||
|-------|-----------|
|
| ----------------- | ---------------------------------------------- |
|
||||||
| Frontend + SSR | TanStack Start (React, Vite) |
|
| Frontend + SSR | TanStack Start (React, Vite) |
|
||||||
| Routing | TanStack Router (file-based) |
|
| Routing | TanStack Router (file-based) |
|
||||||
| API | TanStack Start server handlers (Nitro adapter) |
|
| API | TanStack Start server handlers (Nitro adapter) |
|
||||||
| Database | PostgreSQL via Drizzle ORM |
|
| Database | PostgreSQL via Drizzle ORM |
|
||||||
| Job queue | BullMQ (Redis) |
|
| Job queue | BullMQ (Redis) |
|
||||||
| WhatsApp protocol | Baileys (unofficial, reverse-engineered) |
|
| WhatsApp protocol | Baileys (unofficial, reverse-engineered) |
|
||||||
| Encryption | AES-256-GCM, per-app DEK + master KEK |
|
| Encryption | AES-256-GCM, per-app DEK + master KEK |
|
||||||
| Auth | Iron Session (cookie) + API key (Bearer) |
|
| Auth | Iron Session (cookie) + API key (Bearer) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
| File | Contents |
|
| File | Contents |
|
||||||
|------|----------|
|
| -------------------------------------------- | ----------------------------------------- |
|
||||||
| [docs/quickstart.md](docs/quickstart.md) | Step-by-step getting started guide |
|
| [docs/quickstart.md](docs/quickstart.md) | Step-by-step getting started guide |
|
||||||
| [docs/api.md](docs/api.md) | REST endpoint reference |
|
| [docs/api.md](docs/api.md) | REST endpoint reference |
|
||||||
| [docs/webhooks.md](docs/webhooks.md) | Event catalog, HMAC verification |
|
| [docs/webhooks.md](docs/webhooks.md) | Event catalog, HMAC verification |
|
||||||
| [docs/security.md](docs/security.md) | Encryption model, key rotation, audit log |
|
| [docs/security.md](docs/security.md) | Encryption model, key rotation, audit log |
|
||||||
| [docs/deployment.md](docs/deployment.md) | Docker Compose deploy, backup, restore |
|
| [docs/deployment.md](docs/deployment.md) | Docker Compose deploy, backup, restore |
|
||||||
| [docs/baileys-risk.md](docs/baileys-risk.md) | Baileys risks, ToS, number warming guide |
|
| [docs/baileys-risk.md](docs/baileys-risk.md) | Baileys risks, ToS, number warming guide |
|
||||||
|
|
||||||
Interactive API reference available at `http://localhost:3000/api/docs` when running.
|
Interactive API reference available at `http://localhost:3000/api/docs` when running.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -39,12 +39,12 @@ Risk factors that increase ban likelihood:
|
||||||
|
|
||||||
New numbers should be introduced to automation gradually:
|
New numbers should be introduced to automation gradually:
|
||||||
|
|
||||||
| Week | Max messages/day | Notes |
|
| Week | Max messages/day | Notes |
|
||||||
|------|-----------------|-------|
|
| ---- | ---------------- | ----------------------------- |
|
||||||
| 1 | 50 | Personal-style messages only |
|
| 1 | 50 | Personal-style messages only |
|
||||||
| 2 | 100 | Continue conversational style |
|
| 2 | 100 | Continue conversational style |
|
||||||
| 3 | 200 | Can begin light business use |
|
| 3 | 200 | Can begin light business use |
|
||||||
| 4+ | 500+ | Ramp up based on engagement |
|
| 4+ | 500+ | Ramp up based on engagement |
|
||||||
|
|
||||||
General principles:
|
General principles:
|
||||||
|
|
||||||
|
|
|
||||||
2
drizzle/0007_silky_sentinel.sql
Normal file
2
drizzle/0007_silky_sentinel.sql
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
CREATE TYPE "public"."rotation_status" AS ENUM('pending', 'running', 'done', 'failed');--> statement-breakpoint
|
||||||
|
ALTER TABLE "apps" ALTER COLUMN "rotation_status" SET DATA TYPE "public"."rotation_status" USING "rotation_status"::"public"."rotation_status";
|
||||||
|
|
@ -76,12 +76,8 @@
|
||||||
"name": "api_keys_app_id_apps_id_fk",
|
"name": "api_keys_app_id_apps_id_fk",
|
||||||
"tableFrom": "api_keys",
|
"tableFrom": "api_keys",
|
||||||
"tableTo": "apps",
|
"tableTo": "apps",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["app_id"],
|
||||||
"app_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "no action",
|
"onDelete": "no action",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
}
|
}
|
||||||
|
|
@ -172,12 +168,8 @@
|
||||||
"name": "apps_owner_user_id_users_id_fk",
|
"name": "apps_owner_user_id_users_id_fk",
|
||||||
"tableFrom": "apps",
|
"tableFrom": "apps",
|
||||||
"tableTo": "users",
|
"tableTo": "users",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["owner_user_id"],
|
||||||
"owner_user_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "no action",
|
"onDelete": "no action",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
}
|
}
|
||||||
|
|
@ -345,12 +337,8 @@
|
||||||
"name": "contact_group_members_contact_id_contacts_id_fk",
|
"name": "contact_group_members_contact_id_contacts_id_fk",
|
||||||
"tableFrom": "contact_group_members",
|
"tableFrom": "contact_group_members",
|
||||||
"tableTo": "contacts",
|
"tableTo": "contacts",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["contact_id"],
|
||||||
"contact_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
},
|
},
|
||||||
|
|
@ -358,12 +346,8 @@
|
||||||
"name": "contact_group_members_group_id_contact_groups_id_fk",
|
"name": "contact_group_members_group_id_contact_groups_id_fk",
|
||||||
"tableFrom": "contact_group_members",
|
"tableFrom": "contact_group_members",
|
||||||
"tableTo": "contact_groups",
|
"tableTo": "contact_groups",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["group_id"],
|
||||||
"group_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
}
|
}
|
||||||
|
|
@ -371,10 +355,7 @@
|
||||||
"compositePrimaryKeys": {
|
"compositePrimaryKeys": {
|
||||||
"contact_group_members_contact_id_group_id_pk": {
|
"contact_group_members_contact_id_group_id_pk": {
|
||||||
"name": "contact_group_members_contact_id_group_id_pk",
|
"name": "contact_group_members_contact_id_group_id_pk",
|
||||||
"columns": [
|
"columns": ["contact_id", "group_id"]
|
||||||
"contact_id",
|
|
||||||
"group_id"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"uniqueConstraints": {},
|
"uniqueConstraints": {},
|
||||||
|
|
@ -441,12 +422,8 @@
|
||||||
"name": "contact_groups_app_id_apps_id_fk",
|
"name": "contact_groups_app_id_apps_id_fk",
|
||||||
"tableFrom": "contact_groups",
|
"tableFrom": "contact_groups",
|
||||||
"tableTo": "apps",
|
"tableTo": "apps",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["app_id"],
|
||||||
"app_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "no action",
|
"onDelete": "no action",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
}
|
}
|
||||||
|
|
@ -509,12 +486,8 @@
|
||||||
"name": "contact_tags_app_id_apps_id_fk",
|
"name": "contact_tags_app_id_apps_id_fk",
|
||||||
"tableFrom": "contact_tags",
|
"tableFrom": "contact_tags",
|
||||||
"tableTo": "apps",
|
"tableTo": "apps",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["app_id"],
|
||||||
"app_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "no action",
|
"onDelete": "no action",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
},
|
},
|
||||||
|
|
@ -522,12 +495,8 @@
|
||||||
"name": "contact_tags_contact_id_contacts_id_fk",
|
"name": "contact_tags_contact_id_contacts_id_fk",
|
||||||
"tableFrom": "contact_tags",
|
"tableFrom": "contact_tags",
|
||||||
"tableTo": "contacts",
|
"tableTo": "contacts",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["contact_id"],
|
||||||
"contact_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
}
|
}
|
||||||
|
|
@ -537,10 +506,7 @@
|
||||||
"contact_tags_unique": {
|
"contact_tags_unique": {
|
||||||
"name": "contact_tags_unique",
|
"name": "contact_tags_unique",
|
||||||
"nullsNotDistinct": false,
|
"nullsNotDistinct": false,
|
||||||
"columns": [
|
"columns": ["contact_id", "tag"]
|
||||||
"contact_id",
|
|
||||||
"tag"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"policies": {},
|
"policies": {},
|
||||||
|
|
@ -672,12 +638,8 @@
|
||||||
"name": "contacts_app_id_apps_id_fk",
|
"name": "contacts_app_id_apps_id_fk",
|
||||||
"tableFrom": "contacts",
|
"tableFrom": "contacts",
|
||||||
"tableTo": "apps",
|
"tableTo": "apps",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["app_id"],
|
||||||
"app_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "no action",
|
"onDelete": "no action",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
}
|
}
|
||||||
|
|
@ -687,10 +649,7 @@
|
||||||
"contacts_app_phone_unique": {
|
"contacts_app_phone_unique": {
|
||||||
"name": "contacts_app_phone_unique",
|
"name": "contacts_app_phone_unique",
|
||||||
"nullsNotDistinct": false,
|
"nullsNotDistinct": false,
|
||||||
"columns": [
|
"columns": ["app_id", "phone_blind_index"]
|
||||||
"app_id",
|
|
||||||
"phone_blind_index"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"policies": {},
|
"policies": {},
|
||||||
|
|
@ -771,12 +730,8 @@
|
||||||
"name": "custom_field_schemas_app_id_apps_id_fk",
|
"name": "custom_field_schemas_app_id_apps_id_fk",
|
||||||
"tableFrom": "custom_field_schemas",
|
"tableFrom": "custom_field_schemas",
|
||||||
"tableTo": "apps",
|
"tableTo": "apps",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["app_id"],
|
||||||
"app_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "no action",
|
"onDelete": "no action",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
}
|
}
|
||||||
|
|
@ -786,10 +741,7 @@
|
||||||
"custom_field_schemas_app_key_unique": {
|
"custom_field_schemas_app_key_unique": {
|
||||||
"name": "custom_field_schemas_app_key_unique",
|
"name": "custom_field_schemas_app_key_unique",
|
||||||
"nullsNotDistinct": false,
|
"nullsNotDistinct": false,
|
||||||
"columns": [
|
"columns": ["app_id", "key"]
|
||||||
"app_id",
|
|
||||||
"key"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"policies": {},
|
"policies": {},
|
||||||
|
|
@ -984,12 +936,8 @@
|
||||||
"name": "messages_app_id_apps_id_fk",
|
"name": "messages_app_id_apps_id_fk",
|
||||||
"tableFrom": "messages",
|
"tableFrom": "messages",
|
||||||
"tableTo": "apps",
|
"tableTo": "apps",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["app_id"],
|
||||||
"app_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "no action",
|
"onDelete": "no action",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
},
|
},
|
||||||
|
|
@ -997,12 +945,8 @@
|
||||||
"name": "messages_session_id_sessions_id_fk",
|
"name": "messages_session_id_sessions_id_fk",
|
||||||
"tableFrom": "messages",
|
"tableFrom": "messages",
|
||||||
"tableTo": "sessions",
|
"tableTo": "sessions",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["session_id"],
|
||||||
"session_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
}
|
}
|
||||||
|
|
@ -1062,12 +1006,8 @@
|
||||||
"name": "session_keys_session_id_sessions_id_fk",
|
"name": "session_keys_session_id_sessions_id_fk",
|
||||||
"tableFrom": "session_keys",
|
"tableFrom": "session_keys",
|
||||||
"tableTo": "sessions",
|
"tableTo": "sessions",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["session_id"],
|
||||||
"session_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
}
|
}
|
||||||
|
|
@ -1077,11 +1017,7 @@
|
||||||
"session_keys_unique": {
|
"session_keys_unique": {
|
||||||
"name": "session_keys_unique",
|
"name": "session_keys_unique",
|
||||||
"nullsNotDistinct": false,
|
"nullsNotDistinct": false,
|
||||||
"columns": [
|
"columns": ["session_id", "key_type", "key_id"]
|
||||||
"session_id",
|
|
||||||
"key_type",
|
|
||||||
"key_id"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"policies": {},
|
"policies": {},
|
||||||
|
|
@ -1196,12 +1132,8 @@
|
||||||
"name": "sessions_app_id_apps_id_fk",
|
"name": "sessions_app_id_apps_id_fk",
|
||||||
"tableFrom": "sessions",
|
"tableFrom": "sessions",
|
||||||
"tableTo": "apps",
|
"tableTo": "apps",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["app_id"],
|
||||||
"app_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "no action",
|
"onDelete": "no action",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
}
|
}
|
||||||
|
|
@ -1269,9 +1201,7 @@
|
||||||
"users_email_unique": {
|
"users_email_unique": {
|
||||||
"name": "users_email_unique",
|
"name": "users_email_unique",
|
||||||
"nullsNotDistinct": false,
|
"nullsNotDistinct": false,
|
||||||
"columns": [
|
"columns": ["email"]
|
||||||
"email"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"policies": {},
|
"policies": {},
|
||||||
|
|
@ -1403,12 +1333,8 @@
|
||||||
"name": "webhook_deliveries_endpoint_id_webhook_endpoints_id_fk",
|
"name": "webhook_deliveries_endpoint_id_webhook_endpoints_id_fk",
|
||||||
"tableFrom": "webhook_deliveries",
|
"tableFrom": "webhook_deliveries",
|
||||||
"tableTo": "webhook_endpoints",
|
"tableTo": "webhook_endpoints",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["endpoint_id"],
|
||||||
"endpoint_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "cascade",
|
"onDelete": "cascade",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
},
|
},
|
||||||
|
|
@ -1416,12 +1342,8 @@
|
||||||
"name": "webhook_deliveries_app_id_apps_id_fk",
|
"name": "webhook_deliveries_app_id_apps_id_fk",
|
||||||
"tableFrom": "webhook_deliveries",
|
"tableFrom": "webhook_deliveries",
|
||||||
"tableTo": "apps",
|
"tableTo": "apps",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["app_id"],
|
||||||
"app_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "no action",
|
"onDelete": "no action",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
}
|
}
|
||||||
|
|
@ -1518,12 +1440,8 @@
|
||||||
"name": "webhook_endpoints_app_id_apps_id_fk",
|
"name": "webhook_endpoints_app_id_apps_id_fk",
|
||||||
"tableFrom": "webhook_endpoints",
|
"tableFrom": "webhook_endpoints",
|
||||||
"tableTo": "apps",
|
"tableTo": "apps",
|
||||||
"columnsFrom": [
|
"columnsFrom": ["app_id"],
|
||||||
"app_id"
|
"columnsTo": ["id"],
|
||||||
],
|
|
||||||
"columnsTo": [
|
|
||||||
"id"
|
|
||||||
],
|
|
||||||
"onDelete": "no action",
|
"onDelete": "no action",
|
||||||
"onUpdate": "no action"
|
"onUpdate": "no action"
|
||||||
}
|
}
|
||||||
|
|
@ -1539,59 +1457,32 @@
|
||||||
"public.actor_type": {
|
"public.actor_type": {
|
||||||
"name": "actor_type",
|
"name": "actor_type",
|
||||||
"schema": "public",
|
"schema": "public",
|
||||||
"values": [
|
"values": ["user", "api_key"]
|
||||||
"user",
|
|
||||||
"api_key"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"public.contact_source": {
|
"public.contact_source": {
|
||||||
"name": "contact_source",
|
"name": "contact_source",
|
||||||
"schema": "public",
|
"schema": "public",
|
||||||
"values": [
|
"values": ["csv", "whatsapp_sync", "api"]
|
||||||
"csv",
|
|
||||||
"whatsapp_sync",
|
|
||||||
"api"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"public.custom_field_type": {
|
"public.custom_field_type": {
|
||||||
"name": "custom_field_type",
|
"name": "custom_field_type",
|
||||||
"schema": "public",
|
"schema": "public",
|
||||||
"values": [
|
"values": ["text", "number", "bool", "date"]
|
||||||
"text",
|
|
||||||
"number",
|
|
||||||
"bool",
|
|
||||||
"date"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"public.message_direction": {
|
"public.message_direction": {
|
||||||
"name": "message_direction",
|
"name": "message_direction",
|
||||||
"schema": "public",
|
"schema": "public",
|
||||||
"values": [
|
"values": ["in", "out"]
|
||||||
"in",
|
|
||||||
"out"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"public.message_status": {
|
"public.message_status": {
|
||||||
"name": "message_status",
|
"name": "message_status",
|
||||||
"schema": "public",
|
"schema": "public",
|
||||||
"values": [
|
"values": ["queued", "sent", "delivered", "read", "failed"]
|
||||||
"queued",
|
|
||||||
"sent",
|
|
||||||
"delivered",
|
|
||||||
"read",
|
|
||||||
"failed"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"public.message_type": {
|
"public.message_type": {
|
||||||
"name": "message_type",
|
"name": "message_type",
|
||||||
"schema": "public",
|
"schema": "public",
|
||||||
"values": [
|
"values": ["text", "image", "video", "document", "audio"]
|
||||||
"text",
|
|
||||||
"image",
|
|
||||||
"video",
|
|
||||||
"document",
|
|
||||||
"audio"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"public.session_status": {
|
"public.session_status": {
|
||||||
"name": "session_status",
|
"name": "session_status",
|
||||||
|
|
@ -1609,11 +1500,7 @@
|
||||||
"public.webhook_delivery_status": {
|
"public.webhook_delivery_status": {
|
||||||
"name": "webhook_delivery_status",
|
"name": "webhook_delivery_status",
|
||||||
"schema": "public",
|
"schema": "public",
|
||||||
"values": [
|
"values": ["pending", "success", "failed"]
|
||||||
"pending",
|
|
||||||
"success",
|
|
||||||
"failed"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"schemas": {},
|
"schemas": {},
|
||||||
|
|
@ -1626,4 +1513,4 @@
|
||||||
"schemas": {},
|
"schemas": {},
|
||||||
"tables": {}
|
"tables": {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
1522
drizzle/meta/0007_snapshot.json
Normal file
1522
drizzle/meta/0007_snapshot.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -50,6 +50,13 @@
|
||||||
"when": 1778918643395,
|
"when": 1778918643395,
|
||||||
"tag": "0006_tricky_korg",
|
"tag": "0006_tricky_korg",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 7,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1778920492721,
|
||||||
|
"tag": "0007_silky_sentinel",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,11 @@ export default [
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ignores: ["eslint.config.js", "prettier.config.js", ".output/**", "load-test/**"],
|
ignores: [
|
||||||
|
"eslint.config.js",
|
||||||
|
"prettier.config.js",
|
||||||
|
".output/**",
|
||||||
|
"load-test/**",
|
||||||
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -22,17 +22,14 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.1047.0",
|
"@aws-sdk/client-s3": "^3.1047.0",
|
||||||
"@aws-sdk/s3-request-presigner": "^3.1047.0",
|
"@aws-sdk/s3-request-presigner": "^3.1047.0",
|
||||||
"@faker-js/faker": "^10.3.0",
|
|
||||||
"@opentelemetry/api": "^1.9.1",
|
"@opentelemetry/api": "^1.9.1",
|
||||||
"@opentelemetry/auto-instrumentations-node": "^0.76.0",
|
"@opentelemetry/auto-instrumentations-node": "^0.76.0",
|
||||||
"@opentelemetry/exporter-trace-otlp-http": "^0.218.0",
|
"@opentelemetry/exporter-trace-otlp-http": "^0.218.0",
|
||||||
"@opentelemetry/sdk-node": "^0.218.0",
|
"@opentelemetry/sdk-node": "^0.218.0",
|
||||||
"@tailwindcss/vite": "^4.1.18",
|
"@tailwindcss/vite": "^4.1.18",
|
||||||
"@tanstack/match-sorter-utils": "latest",
|
"@tanstack/match-sorter-utils": "latest",
|
||||||
"@tanstack/react-devtools": "latest",
|
|
||||||
"@tanstack/react-query": "^5.100.10",
|
"@tanstack/react-query": "^5.100.10",
|
||||||
"@tanstack/react-router": "latest",
|
"@tanstack/react-router": "latest",
|
||||||
"@tanstack/react-router-devtools": "latest",
|
|
||||||
"@tanstack/react-router-ssr-query": "latest",
|
"@tanstack/react-router-ssr-query": "latest",
|
||||||
"@tanstack/react-start": "latest",
|
"@tanstack/react-start": "latest",
|
||||||
"@tanstack/react-table": "latest",
|
"@tanstack/react-table": "latest",
|
||||||
|
|
@ -64,9 +61,12 @@
|
||||||
"uuid": "^14.0.0"
|
"uuid": "^14.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@faker-js/faker": "^10.3.0",
|
||||||
"@playwright/test": "^1.60.0",
|
"@playwright/test": "^1.60.0",
|
||||||
"@tailwindcss/typography": "^0.5.16",
|
"@tailwindcss/typography": "^0.5.16",
|
||||||
"@tanstack/devtools-vite": "latest",
|
"@tanstack/devtools-vite": "latest",
|
||||||
|
"@tanstack/react-devtools": "latest",
|
||||||
|
"@tanstack/react-router-devtools": "latest",
|
||||||
"@tanstack/eslint-config": "latest",
|
"@tanstack/eslint-config": "latest",
|
||||||
"@testing-library/dom": "^10.4.1",
|
"@testing-library/dom": "^10.4.1",
|
||||||
"@testing-library/react": "^16.3.0",
|
"@testing-library/react": "^16.3.0",
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,9 @@ export function FieldsDialog({ appId, open, onOpenChange }: Props) {
|
||||||
const [showCreate, setShowCreate] = useState(false);
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
const [newKey, setNewKey] = useState("");
|
const [newKey, setNewKey] = useState("");
|
||||||
const [newLabel, setNewLabel] = useState("");
|
const [newLabel, setNewLabel] = useState("");
|
||||||
const [newType, setNewType] = useState<"text" | "number" | "bool" | "date">("text");
|
const [newType, setNewType] = useState<"text" | "number" | "bool" | "date">(
|
||||||
|
"text",
|
||||||
|
);
|
||||||
const [newRequired, setNewRequired] = useState(false);
|
const [newRequired, setNewRequired] = useState(false);
|
||||||
const [creating, setCreating] = useState(false);
|
const [creating, setCreating] = useState(false);
|
||||||
const [createError, setCreateError] = useState("");
|
const [createError, setCreateError] = useState("");
|
||||||
|
|
@ -102,7 +104,9 @@ export function FieldsDialog({ appId, open, onOpenChange }: Props) {
|
||||||
setNewLabel("");
|
setNewLabel("");
|
||||||
setNewType("text");
|
setNewType("text");
|
||||||
setNewRequired(false);
|
setNewRequired(false);
|
||||||
queryClient.invalidateQueries({ queryKey: ["custom-field-schemas", appId] });
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ["custom-field-schemas", appId],
|
||||||
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setCreateError(err instanceof Error ? err.message : "Failed");
|
setCreateError(err instanceof Error ? err.message : "Failed");
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -118,10 +122,16 @@ export function FieldsDialog({ appId, open, onOpenChange }: Props) {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
headers: { "content-type": "application/json" },
|
headers: { "content-type": "application/json" },
|
||||||
credentials: "include",
|
credentials: "include",
|
||||||
body: JSON.stringify({ app_id: appId, label: editLabel, required: editRequired }),
|
body: JSON.stringify({
|
||||||
|
app_id: appId,
|
||||||
|
label: editLabel,
|
||||||
|
required: editRequired,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
setEditId(null);
|
setEditId(null);
|
||||||
queryClient.invalidateQueries({ queryKey: ["custom-field-schemas", appId] });
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ["custom-field-schemas", appId],
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
|
|
@ -135,7 +145,9 @@ export function FieldsDialog({ appId, open, onOpenChange }: Props) {
|
||||||
`/api/v1/admin/custom-field-schemas/${deleteId}?app_id=${encodeURIComponent(appId)}`,
|
`/api/v1/admin/custom-field-schemas/${deleteId}?app_id=${encodeURIComponent(appId)}`,
|
||||||
{ method: "DELETE", credentials: "include" },
|
{ method: "DELETE", credentials: "include" },
|
||||||
);
|
);
|
||||||
queryClient.invalidateQueries({ queryKey: ["custom-field-schemas", appId] });
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ["custom-field-schemas", appId],
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
setDeleting(false);
|
setDeleting(false);
|
||||||
setDeleteId(null);
|
setDeleteId(null);
|
||||||
|
|
@ -156,7 +168,9 @@ export function FieldsDialog({ appId, open, onOpenChange }: Props) {
|
||||||
>
|
>
|
||||||
<DialogContent className="sm:max-w-[520px]">
|
<DialogContent className="sm:max-w-[520px]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="text-[15px]">Custom field schemas</DialogTitle>
|
<DialogTitle className="text-[15px]">
|
||||||
|
Custom field schemas
|
||||||
|
</DialogTitle>
|
||||||
<DialogDescription className="text-[13px]">
|
<DialogDescription className="text-[13px]">
|
||||||
Define custom fields stored on contacts in this app.
|
Define custom fields stored on contacts in this app.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
|
|
@ -167,11 +181,16 @@ export function FieldsDialog({ appId, open, onOpenChange }: Props) {
|
||||||
<p className="text-[13px] text-gray-400 py-2">Loading…</p>
|
<p className="text-[13px] text-gray-400 py-2">Loading…</p>
|
||||||
)}
|
)}
|
||||||
{!(isPending as boolean) && schemas.length === 0 && !showCreate && (
|
{!(isPending as boolean) && schemas.length === 0 && !showCreate && (
|
||||||
<p className="text-[13px] text-gray-400 py-2">No custom fields defined.</p>
|
<p className="text-[13px] text-gray-400 py-2">
|
||||||
|
No custom fields defined.
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{schemas.map((s) => (
|
{schemas.map((s) => (
|
||||||
<div key={s.id} className="border border-gray-200 rounded-lg overflow-hidden">
|
<div
|
||||||
|
key={s.id}
|
||||||
|
className="border border-gray-200 rounded-lg overflow-hidden"
|
||||||
|
>
|
||||||
{/* row */}
|
{/* row */}
|
||||||
<div className="flex items-center justify-between gap-3 px-3 py-2.5">
|
<div className="flex items-center justify-between gap-3 px-3 py-2.5">
|
||||||
<div className="flex items-center gap-2 min-w-0">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
|
@ -200,7 +219,10 @@ export function FieldsDialog({ appId, open, onOpenChange }: Props) {
|
||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => { setDeleteId(s.id); setEditId(null); }}
|
onClick={() => {
|
||||||
|
setDeleteId(s.id);
|
||||||
|
setEditId(null);
|
||||||
|
}}
|
||||||
className="p-1.5 rounded text-gray-400 hover:text-red-500 hover:bg-red-50 transition-colors"
|
className="p-1.5 rounded text-gray-400 hover:text-red-500 hover:bg-red-50 transition-colors"
|
||||||
>
|
>
|
||||||
<Trash2 size={13} strokeWidth={1.75} />
|
<Trash2 size={13} strokeWidth={1.75} />
|
||||||
|
|
@ -229,7 +251,9 @@ export function FieldsDialog({ appId, open, onOpenChange }: Props) {
|
||||||
onChange={(e) => setEditRequired(e.target.checked)}
|
onChange={(e) => setEditRequired(e.target.checked)}
|
||||||
className="h-3.5 w-3.5"
|
className="h-3.5 w-3.5"
|
||||||
/>
|
/>
|
||||||
<span className="text-[12px] text-gray-700">Required field</span>
|
<span className="text-[12px] text-gray-700">
|
||||||
|
Required field
|
||||||
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -258,7 +282,8 @@ export function FieldsDialog({ appId, open, onOpenChange }: Props) {
|
||||||
{deleteId === s.id && (
|
{deleteId === s.id && (
|
||||||
<div className="border-t border-red-100 bg-red-50 px-3 py-2.5 flex items-center justify-between">
|
<div className="border-t border-red-100 bg-red-50 px-3 py-2.5 flex items-center justify-between">
|
||||||
<p className="text-[12px] text-red-700">
|
<p className="text-[12px] text-red-700">
|
||||||
Delete <strong>{s.label}</strong>? Existing data is not removed.
|
Delete <strong>{s.label}</strong>? Existing data is not
|
||||||
|
removed.
|
||||||
</p>
|
</p>
|
||||||
<div className="flex gap-2 shrink-0 ml-3">
|
<div className="flex gap-2 shrink-0 ml-3">
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -287,7 +312,9 @@ export function FieldsDialog({ appId, open, onOpenChange }: Props) {
|
||||||
{/* inline create form */}
|
{/* inline create form */}
|
||||||
{showCreate && (
|
{showCreate && (
|
||||||
<div className="border border-gray-200 rounded-lg px-3 py-3 space-y-2.5 bg-gray-50">
|
<div className="border border-gray-200 rounded-lg px-3 py-3 space-y-2.5 bg-gray-50">
|
||||||
<p className="text-[12px] font-semibold text-gray-700">New field</p>
|
<p className="text-[12px] font-semibold text-gray-700">
|
||||||
|
New field
|
||||||
|
</p>
|
||||||
<form onSubmit={createSchema} className="space-y-2.5">
|
<form onSubmit={createSchema} className="space-y-2.5">
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
|
|
@ -297,12 +324,16 @@ export function FieldsDialog({ appId, open, onOpenChange }: Props) {
|
||||||
placeholder="e.g. tier"
|
placeholder="e.g. tier"
|
||||||
value={newKey}
|
value={newKey}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setNewKey(e.target.value.replace(/\s+/g, "_").toLowerCase())
|
setNewKey(
|
||||||
|
e.target.value.replace(/\s+/g, "_").toLowerCase(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
className="h-8 text-[13px]"
|
className="h-8 text-[13px]"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<p className="text-[11px] text-gray-400">Immutable after creation</p>
|
<p className="text-[11px] text-gray-400">
|
||||||
|
Immutable after creation
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<Label className="text-[12px] text-gray-600">Label</Label>
|
<Label className="text-[12px] text-gray-600">Label</Label>
|
||||||
|
|
@ -320,7 +351,9 @@ export function FieldsDialog({ appId, open, onOpenChange }: Props) {
|
||||||
<select
|
<select
|
||||||
value={newType}
|
value={newType}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setNewType(e.target.value as "text" | "number" | "bool" | "date")
|
setNewType(
|
||||||
|
e.target.value as "text" | "number" | "bool" | "date",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
className="w-full h-8 text-[13px] border border-input rounded-md px-3 bg-background"
|
className="w-full h-8 text-[13px] border border-input rounded-md px-3 bg-background"
|
||||||
>
|
>
|
||||||
|
|
@ -337,7 +370,9 @@ export function FieldsDialog({ appId, open, onOpenChange }: Props) {
|
||||||
onChange={(e) => setNewRequired(e.target.checked)}
|
onChange={(e) => setNewRequired(e.target.checked)}
|
||||||
className="h-3.5 w-3.5"
|
className="h-3.5 w-3.5"
|
||||||
/>
|
/>
|
||||||
<span className="text-[12px] text-gray-700">Required field</span>
|
<span className="text-[12px] text-gray-700">
|
||||||
|
Required field
|
||||||
|
</span>
|
||||||
</label>
|
</label>
|
||||||
{createError && (
|
{createError && (
|
||||||
<p className="text-[12px] text-red-600 bg-red-50 border border-red-200 rounded px-2 py-1.5">
|
<p className="text-[12px] text-red-600 bg-red-50 border border-red-200 rounded px-2 py-1.5">
|
||||||
|
|
@ -357,7 +392,10 @@ export function FieldsDialog({ appId, open, onOpenChange }: Props) {
|
||||||
type="button"
|
type="button"
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
onClick={() => { setShowCreate(false); setCreateError(""); }}
|
onClick={() => {
|
||||||
|
setShowCreate(false);
|
||||||
|
setCreateError("");
|
||||||
|
}}
|
||||||
className="h-7 px-3 text-[12px]"
|
className="h-7 px-3 text-[12px]"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
|
|
@ -370,7 +408,11 @@ export function FieldsDialog({ appId, open, onOpenChange }: Props) {
|
||||||
|
|
||||||
{!showCreate && (
|
{!showCreate && (
|
||||||
<button
|
<button
|
||||||
onClick={() => { setShowCreate(true); setEditId(null); setDeleteId(null); }}
|
onClick={() => {
|
||||||
|
setShowCreate(true);
|
||||||
|
setEditId(null);
|
||||||
|
setDeleteId(null);
|
||||||
|
}}
|
||||||
className="flex items-center gap-1.5 text-[13px] text-gray-500 hover:text-gray-900 transition-colors mt-1"
|
className="flex items-center gap-1.5 text-[13px] text-gray-500 hover:text-gray-900 transition-colors mt-1"
|
||||||
>
|
>
|
||||||
<Plus size={13} strokeWidth={2} />
|
<Plus size={13} strokeWidth={2} />
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,13 @@ import { relations } from "drizzle-orm";
|
||||||
|
|
||||||
export const actorTypeEnum = pgEnum("actor_type", ["user", "api_key"]);
|
export const actorTypeEnum = pgEnum("actor_type", ["user", "api_key"]);
|
||||||
|
|
||||||
|
export const rotationStatusEnum = pgEnum("rotation_status", [
|
||||||
|
"pending",
|
||||||
|
"running",
|
||||||
|
"done",
|
||||||
|
"failed",
|
||||||
|
]);
|
||||||
|
|
||||||
export const users = pgTable("users", {
|
export const users = pgTable("users", {
|
||||||
id: uuid("id").primaryKey().defaultRandom(),
|
id: uuid("id").primaryKey().defaultRandom(),
|
||||||
email: text("email").notNull().unique(),
|
email: text("email").notNull().unique(),
|
||||||
|
|
@ -34,7 +41,7 @@ export const apps = pgTable("apps", {
|
||||||
.references(() => users.id),
|
.references(() => users.id),
|
||||||
dekEncrypted: text("dek_encrypted").notNull(),
|
dekEncrypted: text("dek_encrypted").notNull(),
|
||||||
maxSessions: integer("max_sessions").notNull().default(10),
|
maxSessions: integer("max_sessions").notNull().default(10),
|
||||||
rotationStatus: text("rotation_status"),
|
rotationStatus: rotationStatusEnum("rotation_status"),
|
||||||
rotationJobId: text("rotation_job_id"),
|
rotationJobId: text("rotation_job_id"),
|
||||||
rotationStartedAt: timestamp("rotation_started_at"),
|
rotationStartedAt: timestamp("rotation_started_at"),
|
||||||
rotationCompletedAt: timestamp("rotation_completed_at"),
|
rotationCompletedAt: timestamp("rotation_completed_at"),
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { and, eq, isNull } from "drizzle-orm";
|
import { and, eq, isNull, notInArray, or } from "drizzle-orm";
|
||||||
import { db } from "#/db/index";
|
import { db } from "#/db/index";
|
||||||
import { apps, apiKeys } from "#/db/schema";
|
import { apps, apiKeys } from "#/db/schema";
|
||||||
import { generateDek, wrapDek } from "#/lib/crypto";
|
import { generateDek, wrapDek } from "#/lib/crypto";
|
||||||
|
|
@ -105,11 +105,33 @@ export async function startKeyRotation(
|
||||||
appId: string,
|
appId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
audit?: { ip?: string; userAgent?: string },
|
audit?: { ip?: string; userAgent?: string },
|
||||||
): Promise<{ ok: true; jobId: string } | { ok: false; error: string; status: number }> {
|
): Promise<
|
||||||
|
{ ok: true; jobId: string } | { ok: false; error: string; status: number }
|
||||||
|
> {
|
||||||
const app = await findUserApp(appId, userId);
|
const app = await findUserApp(appId, userId);
|
||||||
if (!app) return { ok: false, error: "Not found", status: 404 };
|
if (!app) return { ok: false, error: "Not found", status: 404 };
|
||||||
|
|
||||||
if (app.rotationStatus === "pending" || app.rotationStatus === "running") {
|
// Atomic: only proceed if not already pending/running
|
||||||
|
const updated = await db
|
||||||
|
.update(apps)
|
||||||
|
.set({
|
||||||
|
rotationStatus: "pending",
|
||||||
|
rotationJobId: null,
|
||||||
|
rotationStartedAt: new Date(),
|
||||||
|
rotationCompletedAt: null,
|
||||||
|
})
|
||||||
|
.where(
|
||||||
|
and(
|
||||||
|
eq(apps.id, appId),
|
||||||
|
or(
|
||||||
|
isNull(apps.rotationStatus),
|
||||||
|
notInArray(apps.rotationStatus, ["pending", "running"]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.returning({ id: apps.id });
|
||||||
|
|
||||||
|
if (updated.length === 0) {
|
||||||
return { ok: false, error: "Rotation already in progress", status: 409 };
|
return { ok: false, error: "Rotation already in progress", status: 409 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -122,12 +144,7 @@ export async function startKeyRotation(
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.update(apps)
|
.update(apps)
|
||||||
.set({
|
.set({ rotationJobId: String(job.id) })
|
||||||
rotationStatus: "pending",
|
|
||||||
rotationJobId: String(job.id),
|
|
||||||
rotationStartedAt: new Date(),
|
|
||||||
rotationCompletedAt: null,
|
|
||||||
})
|
|
||||||
.where(eq(apps.id, appId));
|
.where(eq(apps.id, appId));
|
||||||
|
|
||||||
await writeAudit({
|
await writeAudit({
|
||||||
|
|
@ -146,7 +163,11 @@ export async function startKeyRotation(
|
||||||
export async function getKeyRotationStatus(
|
export async function getKeyRotationStatus(
|
||||||
appId: string,
|
appId: string,
|
||||||
userId: string,
|
userId: string,
|
||||||
): Promise<{ status: string | null; startedAt: Date | null; completedAt: Date | null } | null> {
|
): Promise<{
|
||||||
|
status: string | null;
|
||||||
|
startedAt: Date | null;
|
||||||
|
completedAt: Date | null;
|
||||||
|
} | null> {
|
||||||
const app = await findUserApp(appId, userId);
|
const app = await findUserApp(appId, userId);
|
||||||
if (!app) return null;
|
if (!app) return null;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,13 @@
|
||||||
import { and, eq, inArray, sql } from "drizzle-orm";
|
import { and, eq, inArray, sql } from "drizzle-orm";
|
||||||
import { db } from "#/db/index";
|
import { db } from "#/db/index";
|
||||||
import { apps, contacts, contactTags, contactGroupMembers } from "#/db/schema";
|
import { contacts, contactTags, contactGroupMembers } from "#/db/schema";
|
||||||
import { unwrapDek } from "#/lib/crypto";
|
|
||||||
import {
|
import {
|
||||||
encryptContact,
|
encryptContact,
|
||||||
decryptContact,
|
decryptContact,
|
||||||
phoneBlindIndex,
|
phoneBlindIndex,
|
||||||
normalizePhone,
|
normalizePhone,
|
||||||
} from "#/lib/contacts-crypto";
|
} from "#/lib/contacts-crypto";
|
||||||
|
import { getAppDek } from "#/lib/dek-helper";
|
||||||
import { dispatchEvent } from "#/lib/events/dispatch";
|
import { dispatchEvent } from "#/lib/events/dispatch";
|
||||||
import { listCustomFieldSchemas } from "#/lib/actions/custom-field-schemas";
|
import { listCustomFieldSchemas } from "#/lib/actions/custom-field-schemas";
|
||||||
|
|
||||||
|
|
@ -38,28 +38,42 @@ async function validateCustomFields(
|
||||||
for (const schema of schemas) {
|
for (const schema of schemas) {
|
||||||
const val = customFields[schema.key];
|
const val = customFields[schema.key];
|
||||||
if (schema.required && (val === undefined || val === null || val === "")) {
|
if (schema.required && (val === undefined || val === null || val === "")) {
|
||||||
return { ok: false, error: `Custom field '${schema.key}' is required`, status: 400 };
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: `Custom field '${schema.key}' is required`,
|
||||||
|
status: 400,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
if (val !== undefined && val !== null) {
|
if (val !== undefined && val !== null) {
|
||||||
if (schema.fieldType === "number" && typeof val !== "number" && isNaN(Number(val))) {
|
if (
|
||||||
return { ok: false, error: `Custom field '${schema.key}' must be a number`, status: 400 };
|
schema.fieldType === "number" &&
|
||||||
|
typeof val !== "number" &&
|
||||||
|
isNaN(Number(val))
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: `Custom field '${schema.key}' must be a number`,
|
||||||
|
status: 400,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
if (schema.fieldType === "bool" && typeof val !== "boolean" && val !== "true" && val !== "false") {
|
if (
|
||||||
return { ok: false, error: `Custom field '${schema.key}' must be a boolean`, status: 400 };
|
schema.fieldType === "bool" &&
|
||||||
|
typeof val !== "boolean" &&
|
||||||
|
val !== "true" &&
|
||||||
|
val !== "false"
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: `Custom field '${schema.key}' must be a boolean`,
|
||||||
|
status: 400,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getAppDek(appId: string): Promise<Buffer> {
|
export { getAppDek };
|
||||||
const app = await db.query.apps.findFirst({
|
|
||||||
where: eq(apps.id, appId),
|
|
||||||
columns: { dekEncrypted: true },
|
|
||||||
});
|
|
||||||
if (!app) throw new Error(`App ${appId} not found`);
|
|
||||||
return unwrapDek(app.dekEncrypted);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createContact(
|
export async function createContact(
|
||||||
appId: string,
|
appId: string,
|
||||||
|
|
@ -74,7 +88,8 @@ export async function createContact(
|
||||||
},
|
},
|
||||||
dek?: Buffer,
|
dek?: Buffer,
|
||||||
): Promise<
|
): Promise<
|
||||||
{ ok: true; contact: Contact } | { ok: false; error: string; status: number }
|
| { ok: true; contact: Contact; created: boolean }
|
||||||
|
| { ok: false; error: string; status: number }
|
||||||
> {
|
> {
|
||||||
if (!data.phone || !data.phone.trim()) {
|
if (!data.phone || !data.phone.trim()) {
|
||||||
return { ok: false, error: "phone is required", status: 400 };
|
return { ok: false, error: "phone is required", status: 400 };
|
||||||
|
|
@ -121,14 +136,13 @@ export async function createContact(
|
||||||
})
|
})
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
const isCreated =
|
const created = contact.createdAt.getTime() === contact.updatedAt.getTime();
|
||||||
contact.createdAt.getTime() === contact.updatedAt.getTime();
|
dispatchEvent(appId, created ? "contact.created" : "contact.updated", {
|
||||||
dispatchEvent(appId, isCreated ? "contact.created" : "contact.updated", {
|
|
||||||
contactId: contact.id,
|
contactId: contact.id,
|
||||||
source: contact.source,
|
source: contact.source,
|
||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
|
|
||||||
return { ok: true, contact };
|
return { ok: true, contact, created };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getTagsForContacts(
|
async function getTagsForContacts(
|
||||||
|
|
@ -174,6 +188,26 @@ export async function findContact(
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function decryptRow(row: Contact, dek: Buffer): DecryptedContact {
|
||||||
|
let decrypted: ReturnType<typeof decryptContact>;
|
||||||
|
try {
|
||||||
|
decrypted = decryptContact(row, dek);
|
||||||
|
} catch {
|
||||||
|
decrypted = { phone: "", name: null, email: null, customFields: null };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: row.id,
|
||||||
|
appId: row.appId,
|
||||||
|
...decrypted,
|
||||||
|
jid: row.jid,
|
||||||
|
pushName: row.pushName,
|
||||||
|
source: row.source,
|
||||||
|
tags: [] as string[],
|
||||||
|
createdAt: row.createdAt,
|
||||||
|
updatedAt: row.updatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export async function listContacts(
|
export async function listContacts(
|
||||||
appId: string,
|
appId: string,
|
||||||
opts: {
|
opts: {
|
||||||
|
|
@ -186,108 +220,109 @@ export async function listContacts(
|
||||||
): Promise<{ contacts: DecryptedContact[]; total: number }> {
|
): Promise<{ contacts: DecryptedContact[]; total: number }> {
|
||||||
const page = Math.max(1, opts.page ?? 1);
|
const page = Math.max(1, opts.page ?? 1);
|
||||||
const limit = Math.min(100, Math.max(1, opts.limit ?? 20));
|
const limit = Math.min(100, Math.max(1, opts.limit ?? 20));
|
||||||
|
const offset = (page - 1) * limit;
|
||||||
|
|
||||||
// Build base query with optional tag/group joins
|
const dek = await getAppDek(appId);
|
||||||
let rows: Contact[];
|
|
||||||
|
|
||||||
|
// Phone search: use blind index — O(1) DB lookup
|
||||||
|
if (opts.q && /^[+\d]/.test(opts.q)) {
|
||||||
|
const blind = phoneBlindIndex(opts.q, dek);
|
||||||
|
const rows = await db.query.contacts.findMany({
|
||||||
|
where: and(
|
||||||
|
eq(contacts.appId, appId),
|
||||||
|
eq(contacts.phoneBlindIndex, blind),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
const decryptedRows = rows.map((r) => decryptRow(r, dek));
|
||||||
|
const paginated = decryptedRows.slice(offset, offset + limit);
|
||||||
|
const tagsMap = await getTagsForContacts(paginated.map((r) => r.id));
|
||||||
|
for (const r of paginated) r.tags = tagsMap.get(r.id) ?? [];
|
||||||
|
return { contacts: paginated, total: decryptedRows.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Name/email search: encrypted, full-scan unavoidable
|
||||||
|
if (opts.q) {
|
||||||
|
const qLower = opts.q.toLowerCase();
|
||||||
|
const rows = await db.query.contacts.findMany({
|
||||||
|
where: eq(contacts.appId, appId),
|
||||||
|
});
|
||||||
|
const decryptedRows: DecryptedContact[] = [];
|
||||||
|
for (const row of rows) {
|
||||||
|
const d = decryptRow(row, dek);
|
||||||
|
if (
|
||||||
|
d.phone.toLowerCase().includes(qLower) ||
|
||||||
|
(d.name?.toLowerCase().includes(qLower) ?? false) ||
|
||||||
|
(d.email?.toLowerCase().includes(qLower) ?? false)
|
||||||
|
) {
|
||||||
|
decryptedRows.push(d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const paginated = decryptedRows.slice(offset, offset + limit);
|
||||||
|
const tagsMap = await getTagsForContacts(paginated.map((r) => r.id));
|
||||||
|
for (const r of paginated) r.tags = tagsMap.get(r.id) ?? [];
|
||||||
|
return { contacts: paginated, total: decryptedRows.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tag filter
|
||||||
if (opts.tag) {
|
if (opts.tag) {
|
||||||
const taggedContactIds = (
|
const taggedIds = (
|
||||||
await db.query.contactTags.findMany({
|
await db.query.contactTags.findMany({
|
||||||
where: and(eq(contactTags.appId, appId), eq(contactTags.tag, opts.tag)),
|
where: and(eq(contactTags.appId, appId), eq(contactTags.tag, opts.tag)),
|
||||||
columns: { contactId: true },
|
columns: { contactId: true },
|
||||||
})
|
})
|
||||||
).map((r) => r.contactId);
|
).map((r) => r.contactId);
|
||||||
|
if (taggedIds.length === 0) return { contacts: [], total: 0 };
|
||||||
|
|
||||||
if (taggedContactIds.length === 0) return { contacts: [], total: 0 };
|
const rows = await db.query.contacts.findMany({
|
||||||
|
where: and(eq(contacts.appId, appId), inArray(contacts.id, taggedIds)),
|
||||||
rows = await db.query.contacts.findMany({
|
limit,
|
||||||
where: and(
|
offset,
|
||||||
eq(contacts.appId, appId),
|
orderBy: (c, { desc }) => [desc(c.createdAt)],
|
||||||
inArray(contacts.id, taggedContactIds),
|
|
||||||
),
|
|
||||||
});
|
});
|
||||||
} else if (opts.groupId) {
|
const decryptedRows = rows.map((r) => decryptRow(r, dek));
|
||||||
|
const tagsMap = await getTagsForContacts(decryptedRows.map((r) => r.id));
|
||||||
|
for (const r of decryptedRows) r.tags = tagsMap.get(r.id) ?? [];
|
||||||
|
return { contacts: decryptedRows, total: taggedIds.length };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group filter
|
||||||
|
if (opts.groupId) {
|
||||||
const memberIds = (
|
const memberIds = (
|
||||||
await db.query.contactGroupMembers.findMany({
|
await db.query.contactGroupMembers.findMany({
|
||||||
where: eq(contactGroupMembers.groupId, opts.groupId),
|
where: eq(contactGroupMembers.groupId, opts.groupId),
|
||||||
columns: { contactId: true },
|
columns: { contactId: true },
|
||||||
})
|
})
|
||||||
).map((r) => r.contactId);
|
).map((r) => r.contactId);
|
||||||
|
|
||||||
if (memberIds.length === 0) return { contacts: [], total: 0 };
|
if (memberIds.length === 0) return { contacts: [], total: 0 };
|
||||||
|
|
||||||
rows = await db.query.contacts.findMany({
|
const rows = await db.query.contacts.findMany({
|
||||||
where: and(eq(contacts.appId, appId), inArray(contacts.id, memberIds)),
|
where: and(eq(contacts.appId, appId), inArray(contacts.id, memberIds)),
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
orderBy: (c, { desc }) => [desc(c.createdAt)],
|
||||||
});
|
});
|
||||||
} else {
|
const decryptedRows = rows.map((r) => decryptRow(r, dek));
|
||||||
rows = await db.query.contacts.findMany({
|
const tagsMap = await getTagsForContacts(decryptedRows.map((r) => r.id));
|
||||||
where: eq(contacts.appId, appId),
|
for (const r of decryptedRows) r.tags = tagsMap.get(r.id) ?? [];
|
||||||
});
|
return { contacts: decryptedRows, total: memberIds.length };
|
||||||
}
|
}
|
||||||
|
|
||||||
const dek = await getAppDek(appId);
|
// No filter: push pagination to DB
|
||||||
|
const [{ count }] = await db
|
||||||
|
.select({ count: sql<number>`count(*)::int` })
|
||||||
|
.from(contacts)
|
||||||
|
.where(eq(contacts.appId, appId));
|
||||||
|
|
||||||
// Decrypt all
|
const rows = await db.query.contacts.findMany({
|
||||||
let decryptedRows: DecryptedContact[] = rows.map((row) => {
|
where: eq(contacts.appId, appId),
|
||||||
let decrypted: ReturnType<typeof decryptContact>;
|
limit,
|
||||||
try {
|
offset,
|
||||||
decrypted = decryptContact(row, dek);
|
orderBy: (c, { desc }) => [desc(c.createdAt)],
|
||||||
} catch {
|
|
||||||
decrypted = { phone: "", name: null, email: null, customFields: null };
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
id: row.id,
|
|
||||||
appId: row.appId,
|
|
||||||
...decrypted,
|
|
||||||
jid: row.jid,
|
|
||||||
pushName: row.pushName,
|
|
||||||
source: row.source,
|
|
||||||
tags: [] as string[],
|
|
||||||
createdAt: row.createdAt,
|
|
||||||
updatedAt: row.updatedAt,
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
const decryptedRows = rows.map((r) => decryptRow(r, dek));
|
||||||
// Client-side filter by q
|
const tagsMap = await getTagsForContacts(decryptedRows.map((r) => r.id));
|
||||||
if (opts.q) {
|
for (const r of decryptedRows) r.tags = tagsMap.get(r.id) ?? [];
|
||||||
const qLower = opts.q.toLowerCase();
|
return { contacts: decryptedRows, total: count };
|
||||||
const isPhone = /^[+\d]/.test(opts.q);
|
|
||||||
if (isPhone) {
|
|
||||||
try {
|
|
||||||
const blind = phoneBlindIndex(opts.q, dek);
|
|
||||||
decryptedRows = decryptedRows.filter((r) => {
|
|
||||||
const row = rows.find((raw) => raw.id === r.id);
|
|
||||||
return row?.phoneBlindIndex === blind;
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
decryptedRows = decryptedRows.filter(
|
|
||||||
(r) =>
|
|
||||||
r.phone.toLowerCase().includes(qLower) ||
|
|
||||||
(r.name?.toLowerCase().includes(qLower) ?? false),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
decryptedRows = decryptedRows.filter(
|
|
||||||
(r) =>
|
|
||||||
r.phone.toLowerCase().includes(qLower) ||
|
|
||||||
(r.name?.toLowerCase().includes(qLower) ?? false) ||
|
|
||||||
(r.email?.toLowerCase().includes(qLower) ?? false),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const total = decryptedRows.length;
|
|
||||||
|
|
||||||
// Paginate
|
|
||||||
const paginated = decryptedRows.slice((page - 1) * limit, page * limit);
|
|
||||||
|
|
||||||
// Load tags for paginated rows
|
|
||||||
const tagsMap = await getTagsForContacts(paginated.map((r) => r.id));
|
|
||||||
for (const r of paginated) {
|
|
||||||
r.tags = tagsMap.get(r.id) ?? [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return { contacts: paginated, total };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function updateContact(
|
export async function updateContact(
|
||||||
|
|
@ -370,23 +405,28 @@ export async function deleteContact(
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DEBUG_CONTACTS = process.env.DEBUG?.includes("baileys");
|
||||||
|
|
||||||
export async function upsertContactFromWhatsApp(
|
export async function upsertContactFromWhatsApp(
|
||||||
appId: string,
|
appId: string,
|
||||||
jid: string,
|
jid: string,
|
||||||
pushName?: string,
|
pushName?: string,
|
||||||
dek?: Buffer,
|
dek?: Buffer,
|
||||||
): Promise<
|
): Promise<
|
||||||
{ ok: true; contact: Contact } | { ok: false; error: string; status: number }
|
| { ok: true; contact: Contact; created: boolean }
|
||||||
|
| { ok: false; error: string; status: number }
|
||||||
> {
|
> {
|
||||||
const phone = jid.split("@")[0];
|
const phone = jid.split("@")[0];
|
||||||
if (!phone || !/^\d+$/.test(phone)) {
|
if (!phone || !/^\d+$/.test(phone)) {
|
||||||
console.log(`[contacts] upsertFromWA skip non-user jid="${jid}"`);
|
if (DEBUG_CONTACTS)
|
||||||
|
console.log(`[contacts] upsertFromWA skip non-user jid="${jid}"`);
|
||||||
return { ok: false, error: `Non-user JID: ${jid}`, status: 400 };
|
return { ok: false, error: `Non-user JID: ${jid}`, status: 400 };
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log(
|
if (DEBUG_CONTACTS)
|
||||||
`[contacts] upsertFromWA jid="${jid}" pushName="${pushName ?? ""}"`,
|
console.log(
|
||||||
);
|
`[contacts] upsertFromWA jid="${jid}" pushName="${pushName ?? ""}"`,
|
||||||
|
);
|
||||||
const result = await createContact(
|
const result = await createContact(
|
||||||
appId,
|
appId,
|
||||||
{
|
{
|
||||||
|
|
@ -398,10 +438,12 @@ export async function upsertContactFromWhatsApp(
|
||||||
},
|
},
|
||||||
dek,
|
dek,
|
||||||
);
|
);
|
||||||
if (result.ok) {
|
if (DEBUG_CONTACTS) {
|
||||||
console.log(`[contacts] saved id=${result.contact.id} phone=${phone}`);
|
if (result.ok) {
|
||||||
} else {
|
console.log(`[contacts] saved id=${result.contact.id} phone=${phone}`);
|
||||||
console.log(`[contacts] save failed: ${result.error}`);
|
} else {
|
||||||
|
console.log(`[contacts] save failed: ${result.error}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import { and, desc, eq, lt, sql } from "drizzle-orm";
|
import { and, desc, eq, notInArray, sql } from "drizzle-orm";
|
||||||
import { db } from "#/db/index";
|
import { db } from "#/db/index";
|
||||||
import { apps, webhookEndpoints, webhookDeliveries } from "#/db/schema";
|
import { webhookEndpoints, webhookDeliveries } from "#/db/schema";
|
||||||
import { encryptColumn, decryptColumn, unwrapDek } from "#/lib/crypto";
|
import { encryptColumn, decryptColumn } from "#/lib/crypto";
|
||||||
|
import { getAppDek } from "#/lib/dek-helper";
|
||||||
import { randomBytes } from "node:crypto";
|
import { randomBytes } from "node:crypto";
|
||||||
|
|
||||||
type WebhookEndpoint = typeof webhookEndpoints.$inferSelect;
|
type WebhookEndpoint = typeof webhookEndpoints.$inferSelect;
|
||||||
|
|
@ -9,13 +10,28 @@ type WebhookDelivery = typeof webhookDeliveries.$inferSelect;
|
||||||
|
|
||||||
const WEBHOOK_LOG_RETAIN = Number(process.env.WEBHOOK_LOG_RETAIN ?? 100);
|
const WEBHOOK_LOG_RETAIN = Number(process.env.WEBHOOK_LOG_RETAIN ?? 100);
|
||||||
|
|
||||||
async function getAppDek(appId: string): Promise<Buffer> {
|
const BLOCKED_HOSTNAMES =
|
||||||
const app = await db.query.apps.findFirst({
|
/^(localhost|127\.\d+\.\d+\.\d+|0\.0\.0\.0|::1|10\.\d+\.\d+\.\d+|172\.(1[6-9]|2\d|3[01])\.\d+\.\d+|192\.168\.\d+\.\d+|169\.254\.\d+\.\d+)$/i;
|
||||||
where: eq(apps.id, appId),
|
|
||||||
columns: { dekEncrypted: true },
|
function validateWebhookUrl(
|
||||||
});
|
raw: string,
|
||||||
if (!app) throw new Error(`App ${appId} not found`);
|
): { ok: true } | { ok: false; error: string } {
|
||||||
return unwrapDek(app.dekEncrypted);
|
let parsed: URL;
|
||||||
|
try {
|
||||||
|
parsed = new URL(raw);
|
||||||
|
} catch {
|
||||||
|
return { ok: false, error: "Invalid URL" };
|
||||||
|
}
|
||||||
|
if (!["http:", "https:"].includes(parsed.protocol)) {
|
||||||
|
return { ok: false, error: "URL must use http or https" };
|
||||||
|
}
|
||||||
|
if (process.env.NODE_ENV === "production" && parsed.protocol !== "https:") {
|
||||||
|
return { ok: false, error: "URL must use https in production" };
|
||||||
|
}
|
||||||
|
if (BLOCKED_HOSTNAMES.test(parsed.hostname)) {
|
||||||
|
return { ok: false, error: "URL targets a private or reserved address" };
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
function generateSecret(): string {
|
function generateSecret(): string {
|
||||||
|
|
@ -61,7 +77,13 @@ export async function createWebhookEndpoint(
|
||||||
url: string,
|
url: string,
|
||||||
events: string[],
|
events: string[],
|
||||||
secretInput?: string,
|
secretInput?: string,
|
||||||
): Promise<{ endpoint: WebhookEndpointPublic; secret: string }> {
|
): Promise<
|
||||||
|
| { ok: true; endpoint: WebhookEndpointPublic; secret: string }
|
||||||
|
| { ok: false; error: string; status: number }
|
||||||
|
> {
|
||||||
|
const urlCheck = validateWebhookUrl(url);
|
||||||
|
if (!urlCheck.ok) return { ok: false, error: urlCheck.error, status: 400 };
|
||||||
|
|
||||||
const dek = await getAppDek(appId);
|
const dek = await getAppDek(appId);
|
||||||
const secret = secretInput ?? generateSecret();
|
const secret = secretInput ?? generateSecret();
|
||||||
const secretEncrypted = encryptColumn(secret, dek);
|
const secretEncrypted = encryptColumn(secret, dek);
|
||||||
|
|
@ -71,7 +93,7 @@ export async function createWebhookEndpoint(
|
||||||
.values({ appId, url, events, secretEncrypted })
|
.values({ appId, url, events, secretEncrypted })
|
||||||
.returning();
|
.returning();
|
||||||
|
|
||||||
return { endpoint: toPublic(row), secret };
|
return { ok: true, endpoint: toPublic(row), secret };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function findWebhookEndpoint(
|
export async function findWebhookEndpoint(
|
||||||
|
|
@ -105,7 +127,11 @@ export async function updateWebhookEndpoint(
|
||||||
if (!existing) return { ok: false, error: "Endpoint not found", status: 404 };
|
if (!existing) return { ok: false, error: "Endpoint not found", status: 404 };
|
||||||
|
|
||||||
const set: Partial<typeof webhookEndpoints.$inferInsert> = {};
|
const set: Partial<typeof webhookEndpoints.$inferInsert> = {};
|
||||||
if (updates.url !== undefined) set.url = updates.url;
|
if (updates.url !== undefined) {
|
||||||
|
const urlCheck = validateWebhookUrl(updates.url);
|
||||||
|
if (!urlCheck.ok) return { ok: false, error: urlCheck.error, status: 400 };
|
||||||
|
set.url = updates.url;
|
||||||
|
}
|
||||||
if (updates.events !== undefined) set.events = updates.events;
|
if (updates.events !== undefined) set.events = updates.events;
|
||||||
if (updates.enabled !== undefined) set.enabled = updates.enabled;
|
if (updates.enabled !== undefined) set.enabled = updates.enabled;
|
||||||
if (updates.secret !== undefined) {
|
if (updates.secret !== undefined) {
|
||||||
|
|
@ -194,7 +220,6 @@ export async function updateDeliverySuccess(
|
||||||
})
|
})
|
||||||
.where(eq(webhookDeliveries.id, deliveryId));
|
.where(eq(webhookDeliveries.id, deliveryId));
|
||||||
|
|
||||||
// Reset consecutive failures on success
|
|
||||||
const delivery = await db.query.webhookDeliveries.findFirst({
|
const delivery = await db.query.webhookDeliveries.findFirst({
|
||||||
where: eq(webhookDeliveries.id, deliveryId),
|
where: eq(webhookDeliveries.id, deliveryId),
|
||||||
columns: { endpointId: true },
|
columns: { endpointId: true },
|
||||||
|
|
@ -209,6 +234,7 @@ export async function updateDeliverySuccess(
|
||||||
|
|
||||||
export async function updateDeliveryFailed(
|
export async function updateDeliveryFailed(
|
||||||
deliveryId: string,
|
deliveryId: string,
|
||||||
|
endpointId: string,
|
||||||
httpStatus: number | null,
|
httpStatus: number | null,
|
||||||
attempt: number,
|
attempt: number,
|
||||||
nextAttemptAt: Date | null,
|
nextAttemptAt: Date | null,
|
||||||
|
|
@ -223,18 +249,12 @@ export async function updateDeliveryFailed(
|
||||||
})
|
})
|
||||||
.where(eq(webhookDeliveries.id, deliveryId));
|
.where(eq(webhookDeliveries.id, deliveryId));
|
||||||
|
|
||||||
const delivery = await db.query.webhookDeliveries.findFirst({
|
|
||||||
where: eq(webhookDeliveries.id, deliveryId),
|
|
||||||
columns: { endpointId: true },
|
|
||||||
});
|
|
||||||
if (!delivery) return;
|
|
||||||
|
|
||||||
const [endpoint] = await db
|
const [endpoint] = await db
|
||||||
.update(webhookEndpoints)
|
.update(webhookEndpoints)
|
||||||
.set({
|
.set({
|
||||||
consecutiveFailures: sql`${webhookEndpoints.consecutiveFailures} + 1`,
|
consecutiveFailures: sql`${webhookEndpoints.consecutiveFailures} + 1`,
|
||||||
})
|
})
|
||||||
.where(eq(webhookEndpoints.id, delivery.endpointId))
|
.where(eq(webhookEndpoints.id, endpointId))
|
||||||
.returning({
|
.returning({
|
||||||
id: webhookEndpoints.id,
|
id: webhookEndpoints.id,
|
||||||
consecutiveFailures: webhookEndpoints.consecutiveFailures,
|
consecutiveFailures: webhookEndpoints.consecutiveFailures,
|
||||||
|
|
@ -274,21 +294,13 @@ export async function pruneDeliveries(endpointId: string): Promise<void> {
|
||||||
|
|
||||||
if (keep.length < WEBHOOK_LOG_RETAIN) return;
|
if (keep.length < WEBHOOK_LOG_RETAIN) return;
|
||||||
|
|
||||||
const oldestKept = keep[keep.length - 1] as (typeof keep)[number] | undefined;
|
const keepIds = keep.map((r) => r.id);
|
||||||
if (!oldestKept) return;
|
|
||||||
|
|
||||||
const cutoffRow = await db.query.webhookDeliveries.findFirst({
|
|
||||||
where: eq(webhookDeliveries.id, oldestKept.id),
|
|
||||||
columns: { createdAt: true },
|
|
||||||
});
|
|
||||||
if (!cutoffRow) return;
|
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.delete(webhookDeliveries)
|
.delete(webhookDeliveries)
|
||||||
.where(
|
.where(
|
||||||
and(
|
and(
|
||||||
eq(webhookDeliveries.endpointId, endpointId),
|
eq(webhookDeliveries.endpointId, endpointId),
|
||||||
lt(webhookDeliveries.createdAt, cutoffRow.createdAt),
|
notInArray(webhookDeliveries.id, keepIds),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ interface ReconnectEntry {
|
||||||
|
|
||||||
const MAX_RECONNECT_WINDOW_MS = 60 * 60 * 1000; // 1h
|
const MAX_RECONNECT_WINDOW_MS = 60 * 60 * 1000; // 1h
|
||||||
const QR_TIMEOUT_MS = 5 * 60 * 1000; // 5 min
|
const QR_TIMEOUT_MS = 5 * 60 * 1000; // 5 min
|
||||||
|
const DEBUG_BAILEYS = process.env.DEBUG?.includes("baileys");
|
||||||
|
|
||||||
// Store on global to survive Vite SSR module isolation / HMR reloads
|
// Store on global to survive Vite SSR module isolation / HMR reloads
|
||||||
declare global {
|
declare global {
|
||||||
|
|
@ -123,13 +124,14 @@ export async function startSession(
|
||||||
// contacts.upsert fires BEFORE connection "open" — register here, not in connection.update
|
// contacts.upsert fires BEFORE connection "open" — register here, not in connection.update
|
||||||
// In Baileys 7.x contact.id may be @lid format; use contact.phoneNumber for the real JID
|
// In Baileys 7.x contact.id may be @lid format; use contact.phoneNumber for the real JID
|
||||||
sock.ev.on("contacts.upsert", async (contactList) => {
|
sock.ev.on("contacts.upsert", async (contactList) => {
|
||||||
console.log(`[contacts.upsert] >>> FIRED count=${contactList.length}`);
|
if (DEBUG_BAILEYS) {
|
||||||
if (contactList[0]) {
|
console.log(`[contacts.upsert] >>> FIRED count=${contactList.length}`);
|
||||||
// Log raw shape of first contact so we see all available fields
|
if (contactList[0]) {
|
||||||
console.log(
|
console.log(
|
||||||
`[contacts.upsert] sample[0] raw=`,
|
`[contacts.upsert] sample[0] raw=`,
|
||||||
JSON.stringify(contactList[0]),
|
JSON.stringify(contactList[0]),
|
||||||
);
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const { upsertContactFromWhatsApp } =
|
const { upsertContactFromWhatsApp } =
|
||||||
await import("#/lib/actions/contacts");
|
await import("#/lib/actions/contacts");
|
||||||
|
|
@ -146,12 +148,14 @@ export async function startSession(
|
||||||
});
|
});
|
||||||
|
|
||||||
sock.ev.on("contacts.update", (updates) => {
|
sock.ev.on("contacts.update", (updates) => {
|
||||||
console.log(`[contacts.update] >>> FIRED count=${updates.length}`);
|
if (DEBUG_BAILEYS) {
|
||||||
if (updates[0]) {
|
console.log(`[contacts.update] >>> FIRED count=${updates.length}`);
|
||||||
console.log(
|
if (updates[0]) {
|
||||||
`[contacts.update] sample[0] raw=`,
|
console.log(
|
||||||
JSON.stringify(updates[0]),
|
`[contacts.update] sample[0] raw=`,
|
||||||
);
|
JSON.stringify(updates[0]),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -176,9 +180,10 @@ export async function startSession(
|
||||||
// Upsert contact on every message — reliable fallback for when contacts.upsert misses
|
// Upsert contact on every message — reliable fallback for when contacts.upsert misses
|
||||||
if (jid.endsWith("@s.whatsapp.net")) {
|
if (jid.endsWith("@s.whatsapp.net")) {
|
||||||
const pushName = (msg as { pushName?: string }).pushName ?? undefined;
|
const pushName = (msg as { pushName?: string }).pushName ?? undefined;
|
||||||
console.log(
|
if (DEBUG_BAILEYS)
|
||||||
`[contacts] msg-fallback upsert jid="${jid}" pushName="${pushName ?? ""}"`,
|
console.log(
|
||||||
);
|
`[contacts] msg-fallback upsert jid="${jid}" pushName="${pushName ?? ""}"`,
|
||||||
|
);
|
||||||
import("#/lib/actions/contacts")
|
import("#/lib/actions/contacts")
|
||||||
.then(({ upsertContactFromWhatsApp }) =>
|
.then(({ upsertContactFromWhatsApp }) =>
|
||||||
upsertContactFromWhatsApp(appId, jid, pushName, dek),
|
upsertContactFromWhatsApp(appId, jid, pushName, dek),
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,15 @@ import { apps } from "#/db/schema";
|
||||||
import { unwrapDek } from "#/lib/crypto";
|
import { unwrapDek } from "#/lib/crypto";
|
||||||
import { getAppDekForSession } from "#/lib/baileys";
|
import { getAppDekForSession } from "#/lib/baileys";
|
||||||
|
|
||||||
|
export async function getAppDek(appId: string): Promise<Buffer> {
|
||||||
|
const app = await db.query.apps.findFirst({
|
||||||
|
where: eq(apps.id, appId),
|
||||||
|
columns: { dekEncrypted: true },
|
||||||
|
});
|
||||||
|
if (!app) throw new Error(`App ${appId} not found`);
|
||||||
|
return unwrapDek(app.dekEncrypted);
|
||||||
|
}
|
||||||
|
|
||||||
export async function getSessionDek(
|
export async function getSessionDek(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
appId: string,
|
appId: string,
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -78,11 +78,7 @@ async function processImport(job: Job<ImportJob>): Promise<ImportResult> {
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
// Determine if created or updated based on timestamps
|
if (result.created) {
|
||||||
const c = result.contact;
|
|
||||||
const isNew =
|
|
||||||
Math.abs(c.createdAt.getTime() - c.updatedAt.getTime()) < 1000;
|
|
||||||
if (isNew) {
|
|
||||||
created++;
|
created++;
|
||||||
} else {
|
} else {
|
||||||
updated++;
|
updated++;
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,13 @@ import type { Worker } from "bullmq";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { createWorker, QUEUE_KEY_ROTATION } from "./index";
|
import { createWorker, QUEUE_KEY_ROTATION } from "./index";
|
||||||
import { db } from "#/db/index";
|
import { db } from "#/db/index";
|
||||||
import { apps, contacts, sessions } from "#/db/schema";
|
import {
|
||||||
|
apps,
|
||||||
|
contacts,
|
||||||
|
sessions,
|
||||||
|
messages,
|
||||||
|
webhookEndpoints,
|
||||||
|
} from "#/db/schema";
|
||||||
import { unwrapDek, encryptColumn, decryptColumn } from "#/lib/crypto";
|
import { unwrapDek, encryptColumn, decryptColumn } from "#/lib/crypto";
|
||||||
import { phoneBlindIndex } from "#/lib/contacts-crypto";
|
import { phoneBlindIndex } from "#/lib/contacts-crypto";
|
||||||
import { writeAudit } from "#/lib/audit";
|
import { writeAudit } from "#/lib/audit";
|
||||||
|
|
@ -54,10 +60,16 @@ export function startRotationWorker(): Worker {
|
||||||
};
|
};
|
||||||
|
|
||||||
if (row.nameEnc) {
|
if (row.nameEnc) {
|
||||||
updates.nameEnc = encryptColumn(decryptColumn(row.nameEnc, oldDek), newDek);
|
updates.nameEnc = encryptColumn(
|
||||||
|
decryptColumn(row.nameEnc, oldDek),
|
||||||
|
newDek,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (row.emailEnc) {
|
if (row.emailEnc) {
|
||||||
updates.emailEnc = encryptColumn(decryptColumn(row.emailEnc, oldDek), newDek);
|
updates.emailEnc = encryptColumn(
|
||||||
|
decryptColumn(row.emailEnc, oldDek),
|
||||||
|
newDek,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (row.customFieldsEnc) {
|
if (row.customFieldsEnc) {
|
||||||
updates.customFieldsEnc = encryptColumn(
|
updates.customFieldsEnc = encryptColumn(
|
||||||
|
|
@ -83,6 +95,33 @@ export function startRotationWorker(): Worker {
|
||||||
.where(eq(sessions.id, row.id));
|
.where(eq(sessions.id, row.id));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (process.env.ENCRYPT_MESSAGE_BODY === "true") {
|
||||||
|
const messageRows = await db.query.messages.findMany({
|
||||||
|
where: eq(messages.appId, appId),
|
||||||
|
columns: { id: true, bodyEncrypted: true },
|
||||||
|
});
|
||||||
|
for (const row of messageRows) {
|
||||||
|
if (!row.bodyEncrypted) continue;
|
||||||
|
const plain = decryptColumn(row.bodyEncrypted, oldDek);
|
||||||
|
await db
|
||||||
|
.update(messages)
|
||||||
|
.set({ bodyEncrypted: encryptColumn(plain, newDek) })
|
||||||
|
.where(eq(messages.id, row.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const webhookRows = await db.query.webhookEndpoints.findMany({
|
||||||
|
where: eq(webhookEndpoints.appId, appId),
|
||||||
|
columns: { id: true, secretEncrypted: true },
|
||||||
|
});
|
||||||
|
for (const row of webhookRows) {
|
||||||
|
const plain = decryptColumn(row.secretEncrypted, oldDek);
|
||||||
|
await db
|
||||||
|
.update(webhookEndpoints)
|
||||||
|
.set({ secretEncrypted: encryptColumn(plain, newDek) })
|
||||||
|
.where(eq(webhookEndpoints.id, row.id));
|
||||||
|
}
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.update(apps)
|
.update(apps)
|
||||||
.set({
|
.set({
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,7 @@ export function startWebhookWorker(): Worker {
|
||||||
|
|
||||||
const secret = await getEndpointSecret(endpointId, appId);
|
const secret = await getEndpointSecret(endpointId, appId);
|
||||||
if (!secret) {
|
if (!secret) {
|
||||||
await updateDeliveryFailed(deliveryId, null, attempt, null);
|
await updateDeliveryFailed(deliveryId, endpointId, null, attempt, null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -89,12 +89,15 @@ export function startWebhookWorker(): Worker {
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextAttemptIndex = attempt + 1;
|
const nextAttemptIndex = attempt + 1;
|
||||||
const nextDelay = (RETRY_DELAYS as (number | undefined)[])[nextAttemptIndex];
|
const nextDelay = (RETRY_DELAYS as (number | undefined)[])[
|
||||||
|
nextAttemptIndex
|
||||||
|
];
|
||||||
const nextAttemptAt =
|
const nextAttemptAt =
|
||||||
nextDelay !== undefined ? new Date(Date.now() + nextDelay) : null;
|
nextDelay !== undefined ? new Date(Date.now() + nextDelay) : null;
|
||||||
|
|
||||||
await updateDeliveryFailed(
|
await updateDeliveryFailed(
|
||||||
deliveryId,
|
deliveryId,
|
||||||
|
endpointId,
|
||||||
httpStatus,
|
httpStatus,
|
||||||
attempt + 1,
|
attempt + 1,
|
||||||
nextAttemptAt,
|
nextAttemptAt,
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,19 @@
|
||||||
import { createFileRoute } from "@tanstack/react-router";
|
import { createFileRoute } from "@tanstack/react-router";
|
||||||
|
import { RateLimiterRedis } from "rate-limiter-flexible";
|
||||||
|
import Redis from "ioredis";
|
||||||
import { verifyPassword } from "#/lib/password";
|
import { verifyPassword } from "#/lib/password";
|
||||||
import { createSessionCookie, json, jsonError } from "#/lib/session-api";
|
import { createSessionCookie, json, jsonError } from "#/lib/session-api";
|
||||||
import { writeAudit } from "#/lib/audit";
|
import { writeAudit } from "#/lib/audit";
|
||||||
import { findUserByEmail, verifyTotpToken } from "#/lib/actions/auth";
|
import { findUserByEmail, verifyTotpToken } from "#/lib/actions/auth";
|
||||||
|
import { getRedisConnection } from "#/lib/queue";
|
||||||
|
|
||||||
const loginAttempts = new Map<string, { count: number; resetAt: number }>();
|
const loginLimiter = new RateLimiterRedis({
|
||||||
const LOGIN_WINDOW_MS = 15 * 60 * 1000;
|
storeClient: new Redis(getRedisConnection()),
|
||||||
const LOGIN_LIMIT = 10;
|
keyPrefix: "login_rl",
|
||||||
|
points: 10,
|
||||||
function checkLoginRateLimit(ip: string): boolean {
|
duration: 15 * 60,
|
||||||
const now = Date.now();
|
blockDuration: 15 * 60,
|
||||||
const entry = loginAttempts.get(ip);
|
});
|
||||||
if (!entry || now > entry.resetAt) {
|
|
||||||
loginAttempts.set(ip, { count: 1, resetAt: now + LOGIN_WINDOW_MS });
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (entry.count >= LOGIN_LIMIT) return false;
|
|
||||||
entry.count++;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Route = createFileRoute("/api/auth/login")({
|
export const Route = createFileRoute("/api/auth/login")({
|
||||||
server: {
|
server: {
|
||||||
|
|
@ -30,7 +25,9 @@ export const Route = createFileRoute("/api/auth/login")({
|
||||||
request.headers.get("x-real-ip") ??
|
request.headers.get("x-real-ip") ??
|
||||||
"unknown";
|
"unknown";
|
||||||
|
|
||||||
if (!checkLoginRateLimit(ip)) {
|
try {
|
||||||
|
await loginLimiter.consume(ip);
|
||||||
|
} catch {
|
||||||
return jsonError("Too many login attempts", 429);
|
return jsonError("Too many login attempts", 429);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,11 @@ export const Route = createFileRoute(
|
||||||
if (!Array.isArray(body.contact_ids) || body.contact_ids.length === 0) {
|
if (!Array.isArray(body.contact_ids) || body.contact_ids.length === 0) {
|
||||||
return jsonError("contact_ids array is required", 400);
|
return jsonError("contact_ids array is required", 400);
|
||||||
}
|
}
|
||||||
const result = await addGroupMembers(params.id, appId, body.contact_ids);
|
const result = await addGroupMembers(
|
||||||
|
params.id,
|
||||||
|
appId,
|
||||||
|
body.contact_ids,
|
||||||
|
);
|
||||||
if (!result.ok) return jsonError(result.error, result.status);
|
if (!result.ok) return jsonError(result.error, result.status);
|
||||||
return json({ added: result.added }, 201);
|
return json({ added: result.added }, 201);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -29,13 +29,14 @@ export const Route = createFileRoute("/api/v1/admin/webhooks/")({
|
||||||
if (!body.url) return jsonError("url is required", 400);
|
if (!body.url) return jsonError("url is required", 400);
|
||||||
if (!Array.isArray(body.events))
|
if (!Array.isArray(body.events))
|
||||||
return jsonError("events must be an array", 400);
|
return jsonError("events must be an array", 400);
|
||||||
const { endpoint, secret } = await createWebhookEndpoint(
|
const result = await createWebhookEndpoint(
|
||||||
body.app_id,
|
body.app_id,
|
||||||
body.url,
|
body.url,
|
||||||
body.events,
|
body.events,
|
||||||
body.secret ?? undefined,
|
body.secret ?? undefined,
|
||||||
);
|
);
|
||||||
return json({ ...endpoint, secret }, 201);
|
if (!result.ok) return jsonError(result.error, result.status);
|
||||||
|
return json({ ...result.endpoint, secret: result.secret }, 201);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -19,18 +19,28 @@ export const Route = createFileRoute("/api/v1/contacts/import/")({
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse multipart
|
// Parse multipart
|
||||||
|
const MAX_CSV_BYTES = 10 * 1024 * 1024; // 10 MB
|
||||||
let csvContent = "";
|
let csvContent = "";
|
||||||
let columnMapRaw = "";
|
let columnMapRaw = "";
|
||||||
let dryRunRaw = "false";
|
let dryRunRaw = "false";
|
||||||
|
let csvTooLarge = false;
|
||||||
|
|
||||||
await new Promise<void>((resolve, reject) => {
|
await new Promise<void>((resolve, reject) => {
|
||||||
const bb = Busboy({ headers: { "content-type": contentType } });
|
const bb = Busboy({
|
||||||
|
headers: { "content-type": contentType },
|
||||||
|
limits: { fileSize: MAX_CSV_BYTES },
|
||||||
|
});
|
||||||
|
|
||||||
bb.on("file", (_field, stream) => {
|
bb.on("file", (_field, stream) => {
|
||||||
const chunks: Buffer[] = [];
|
const chunks: Buffer[] = [];
|
||||||
stream.on("data", (chunk: Buffer) => chunks.push(chunk));
|
stream.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||||
|
stream.on("limit", () => {
|
||||||
|
csvTooLarge = true;
|
||||||
|
stream.resume();
|
||||||
|
});
|
||||||
stream.on("end", () => {
|
stream.on("end", () => {
|
||||||
csvContent = Buffer.concat(chunks).toString("utf8");
|
if (!csvTooLarge)
|
||||||
|
csvContent = Buffer.concat(chunks).toString("utf8");
|
||||||
});
|
});
|
||||||
stream.on("error", reject);
|
stream.on("error", reject);
|
||||||
});
|
});
|
||||||
|
|
@ -52,6 +62,11 @@ export const Route = createFileRoute("/api/v1/contacts/import/")({
|
||||||
.catch(reject);
|
.catch(reject);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||||
|
if (csvTooLarge) {
|
||||||
|
return jsonError("CSV file exceeds 10 MB limit", 413);
|
||||||
|
}
|
||||||
|
|
||||||
if (!csvContent.trim()) {
|
if (!csvContent.trim()) {
|
||||||
return jsonError("CSV file is required", 400);
|
return jsonError("CSV file is required", 400);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,13 @@ export const Route = createFileRoute("/api/v1/sessions/$id/messages/broadcast")(
|
||||||
400,
|
400,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
const MAX_RECIPIENTS = 1000;
|
||||||
|
if (body.recipients.length > MAX_RECIPIENTS) {
|
||||||
|
return jsonError(
|
||||||
|
`recipients must not exceed ${MAX_RECIPIENTS}`,
|
||||||
|
400,
|
||||||
|
);
|
||||||
|
}
|
||||||
if (!body.type || (!body.text && !body.url)) {
|
if (!body.type || (!body.text && !body.url)) {
|
||||||
return jsonError("Missing message content (text or url)", 400);
|
return jsonError("Missing message content (text or url)", 400);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,14 +24,15 @@ export const Route = createFileRoute("/api/v1/webhooks/")({
|
||||||
if (!Array.isArray(body.events))
|
if (!Array.isArray(body.events))
|
||||||
return jsonError("events must be an array", 400);
|
return jsonError("events must be an array", 400);
|
||||||
|
|
||||||
const { endpoint, secret } = await createWebhookEndpoint(
|
const result = await createWebhookEndpoint(
|
||||||
auth.appId,
|
auth.appId,
|
||||||
body.url,
|
body.url,
|
||||||
body.events,
|
body.events,
|
||||||
body.secret ?? undefined,
|
body.secret ?? undefined,
|
||||||
);
|
);
|
||||||
|
if (!result.ok) return jsonError(result.error, result.status);
|
||||||
|
|
||||||
return json({ ...endpoint, secret }, 201);
|
return json({ ...result.endpoint, secret: result.secret }, 201);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -72,11 +72,7 @@ function StepBar({ current }: { current: Step }) {
|
||||||
: "bg-gray-100 text-gray-400 border border-gray-200",
|
: "bg-gray-100 text-gray-400 border border-gray-200",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{isDone ? (
|
{isDone ? <CheckCircle size={12} strokeWidth={2.5} /> : i + 1}
|
||||||
<CheckCircle size={12} strokeWidth={2.5} />
|
|
||||||
) : (
|
|
||||||
i + 1
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<span
|
<span
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
@ -345,7 +341,6 @@ function ContactImportPage() {
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="flex-1 px-8 py-8">
|
<div className="flex-1 px-8 py-8">
|
||||||
|
|
||||||
{/* ── UPLOAD ── */}
|
{/* ── UPLOAD ── */}
|
||||||
{step === "upload" && (
|
{step === "upload" && (
|
||||||
<div className="grid grid-cols-[320px_1fr] gap-10 items-start max-w-5xl">
|
<div className="grid grid-cols-[320px_1fr] gap-10 items-start max-w-5xl">
|
||||||
|
|
@ -368,7 +363,11 @@ function ContactImportPage() {
|
||||||
].map((req) => (
|
].map((req) => (
|
||||||
<div key={req} className="flex items-start gap-2.5">
|
<div key={req} className="flex items-start gap-2.5">
|
||||||
<div className="w-4 h-4 rounded-full bg-zinc-900 flex items-center justify-center shrink-0 mt-0.5">
|
<div className="w-4 h-4 rounded-full bg-zinc-900 flex items-center justify-center shrink-0 mt-0.5">
|
||||||
<CheckCircle size={9} className="text-white" strokeWidth={3} />
|
<CheckCircle
|
||||||
|
size={9}
|
||||||
|
className="text-white"
|
||||||
|
strokeWidth={3}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[13px] text-gray-600">{req}</p>
|
<p className="text-[13px] text-gray-600">{req}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -380,7 +379,7 @@ function ContactImportPage() {
|
||||||
Example format
|
Example format
|
||||||
</p>
|
</p>
|
||||||
<pre className="text-[11px] text-gray-600 font-mono leading-relaxed">
|
<pre className="text-[11px] text-gray-600 font-mono leading-relaxed">
|
||||||
{`phone,name,email
|
{`phone,name,email
|
||||||
+6281234567890,John Doe,john@co.com
|
+6281234567890,John Doe,john@co.com
|
||||||
+6289876543210,Jane Smith,`}
|
+6289876543210,Jane Smith,`}
|
||||||
</pre>
|
</pre>
|
||||||
|
|
@ -406,14 +405,19 @@ function ContactImportPage() {
|
||||||
{file ? (
|
{file ? (
|
||||||
<div className="flex items-center gap-5 px-8 py-8">
|
<div className="flex items-center gap-5 px-8 py-8">
|
||||||
<div className="w-12 h-12 rounded-xl bg-zinc-900 flex items-center justify-center shrink-0">
|
<div className="w-12 h-12 rounded-xl bg-zinc-900 flex items-center justify-center shrink-0">
|
||||||
<FileText size={20} className="text-white" strokeWidth={1.5} />
|
<FileText
|
||||||
|
size={20}
|
||||||
|
className="text-white"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-[14px] font-semibold text-gray-900 truncate">
|
<p className="text-[14px] font-semibold text-gray-900 truncate">
|
||||||
{file.name}
|
{file.name}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-[12px] text-gray-400 mt-0.5">
|
<p className="text-[12px] text-gray-400 mt-0.5">
|
||||||
{(file.size / 1024).toFixed(1)} KB · CSV file · ready to map
|
{(file.size / 1024).toFixed(1)} KB · CSV file · ready to
|
||||||
|
map
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
|
@ -519,40 +523,61 @@ function ContactImportPage() {
|
||||||
setter: setEmailCol,
|
setter: setEmailCol,
|
||||||
required: false,
|
required: false,
|
||||||
},
|
},
|
||||||
].map(({ key, label, hint, icon: Icon, value, setter, required }) => (
|
].map(
|
||||||
<div key={key} className="flex items-center gap-4 px-4 py-3.5">
|
({
|
||||||
<div className="w-8 h-8 rounded-lg bg-gray-100 flex items-center justify-center shrink-0">
|
key,
|
||||||
<Icon size={14} className="text-gray-500" strokeWidth={1.75} />
|
label,
|
||||||
</div>
|
hint,
|
||||||
<div className="flex-1 min-w-0">
|
icon: Icon,
|
||||||
<div className="flex items-center gap-1.5 mb-1">
|
value,
|
||||||
<p className="text-[13px] font-medium text-gray-900">
|
setter,
|
||||||
{label}
|
required,
|
||||||
</p>
|
}) => (
|
||||||
<span
|
<div
|
||||||
className={cn(
|
key={key}
|
||||||
"text-[10px] font-medium rounded-full px-1.5 py-0.5",
|
className="flex items-center gap-4 px-4 py-3.5"
|
||||||
required
|
>
|
||||||
? "bg-zinc-900 text-white"
|
<div className="w-8 h-8 rounded-lg bg-gray-100 flex items-center justify-center shrink-0">
|
||||||
: "bg-gray-100 text-gray-400",
|
<Icon
|
||||||
)}
|
size={14}
|
||||||
>
|
className="text-gray-500"
|
||||||
{hint}
|
strokeWidth={1.75}
|
||||||
</span>
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-1.5 mb-1">
|
||||||
|
<p className="text-[13px] font-medium text-gray-900">
|
||||||
|
{label}
|
||||||
|
</p>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-[10px] font-medium rounded-full px-1.5 py-0.5",
|
||||||
|
required
|
||||||
|
? "bg-zinc-900 text-white"
|
||||||
|
: "bg-gray-100 text-gray-400",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{hint}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => setter(e.target.value)}
|
||||||
|
className="w-full h-8 px-2 text-[12px] bg-gray-50 border border-gray-200 rounded-lg outline-none focus:ring-2 focus:ring-zinc-900 focus:bg-white transition-all"
|
||||||
|
>
|
||||||
|
<option value="">
|
||||||
|
{required ? "Select column…" : "None"}
|
||||||
|
</option>
|
||||||
|
{headers.map((h) => (
|
||||||
|
<option key={h} value={h}>
|
||||||
|
{h}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<select
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => setter(e.target.value)}
|
|
||||||
className="w-full h-8 px-2 text-[12px] bg-gray-50 border border-gray-200 rounded-lg outline-none focus:ring-2 focus:ring-zinc-900 focus:bg-white transition-all"
|
|
||||||
>
|
|
||||||
<option value="">{required ? "Select column…" : "None"}</option>
|
|
||||||
{headers.map((h) => (
|
|
||||||
<option key={h} value={h}>{h}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
),
|
||||||
))}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{previewError && (
|
{previewError && (
|
||||||
|
|
@ -592,7 +617,11 @@ function ContactImportPage() {
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-[13px] font-semibold text-gray-700 mb-1 flex items-center gap-2">
|
<h3 className="text-[13px] font-semibold text-gray-700 mb-1 flex items-center gap-2">
|
||||||
<TableProperties size={14} className="text-gray-400" strokeWidth={1.75} />
|
<TableProperties
|
||||||
|
size={14}
|
||||||
|
className="text-gray-400"
|
||||||
|
strokeWidth={1.75}
|
||||||
|
/>
|
||||||
Detected columns
|
Detected columns
|
||||||
<span className="text-[11px] font-medium text-gray-400 bg-gray-100 rounded-full px-2 py-0.5">
|
<span className="text-[11px] font-medium text-gray-400 bg-gray-100 rounded-full px-2 py-0.5">
|
||||||
{headers.length}
|
{headers.length}
|
||||||
|
|
@ -681,7 +710,10 @@ function ContactImportPage() {
|
||||||
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden">
|
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden">
|
||||||
<div className="grid grid-cols-3 gap-0 border-b border-gray-100 bg-gray-50">
|
<div className="grid grid-cols-3 gap-0 border-b border-gray-100 bg-gray-50">
|
||||||
{["Phone", "Name", "Email"].map((col) => (
|
{["Phone", "Name", "Email"].map((col) => (
|
||||||
<div key={col} className="px-5 py-3 border-r border-gray-100 last:border-r-0">
|
<div
|
||||||
|
key={col}
|
||||||
|
className="px-5 py-3 border-r border-gray-100 last:border-r-0"
|
||||||
|
>
|
||||||
<span className="text-[10px] font-bold text-gray-400 uppercase tracking-wider">
|
<span className="text-[10px] font-bold text-gray-400 uppercase tracking-wider">
|
||||||
{col}
|
{col}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -754,7 +786,11 @@ function ContactImportPage() {
|
||||||
{step === "importing" && (
|
{step === "importing" && (
|
||||||
<div className="flex flex-col items-center justify-center min-h-[400px] gap-5">
|
<div className="flex flex-col items-center justify-center min-h-[400px] gap-5">
|
||||||
<div className="w-16 h-16 rounded-2xl bg-zinc-900 flex items-center justify-center">
|
<div className="w-16 h-16 rounded-2xl bg-zinc-900 flex items-center justify-center">
|
||||||
<Loader size={24} className="text-white animate-spin" strokeWidth={1.5} />
|
<Loader
|
||||||
|
size={24}
|
||||||
|
className="text-white animate-spin"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<p className="text-[16px] font-semibold text-gray-900 mb-1.5">
|
<p className="text-[16px] font-semibold text-gray-900 mb-1.5">
|
||||||
|
|
@ -788,7 +824,11 @@ function ContactImportPage() {
|
||||||
<div className="grid grid-cols-3 gap-4">
|
<div className="grid grid-cols-3 gap-4">
|
||||||
<div className="bg-white border border-emerald-200 rounded-xl p-5">
|
<div className="bg-white border border-emerald-200 rounded-xl p-5">
|
||||||
<div className="w-8 h-8 rounded-lg bg-emerald-50 border border-emerald-100 flex items-center justify-center mb-3">
|
<div className="w-8 h-8 rounded-lg bg-emerald-50 border border-emerald-100 flex items-center justify-center mb-3">
|
||||||
<CheckCircle size={15} className="text-emerald-600" strokeWidth={2} />
|
<CheckCircle
|
||||||
|
size={15}
|
||||||
|
className="text-emerald-600"
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[28px] font-bold text-gray-900 tabular-nums leading-none mb-1">
|
<p className="text-[28px] font-bold text-gray-900 tabular-nums leading-none mb-1">
|
||||||
{importResult.created}
|
{importResult.created}
|
||||||
|
|
@ -800,14 +840,16 @@ function ContactImportPage() {
|
||||||
|
|
||||||
<div className="bg-white border border-blue-200 rounded-xl p-5">
|
<div className="bg-white border border-blue-200 rounded-xl p-5">
|
||||||
<div className="w-8 h-8 rounded-lg bg-blue-50 border border-blue-100 flex items-center justify-center mb-3">
|
<div className="w-8 h-8 rounded-lg bg-blue-50 border border-blue-100 flex items-center justify-center mb-3">
|
||||||
<CheckCircle size={15} className="text-blue-500" strokeWidth={2} />
|
<CheckCircle
|
||||||
|
size={15}
|
||||||
|
className="text-blue-500"
|
||||||
|
strokeWidth={2}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[28px] font-bold text-gray-900 tabular-nums leading-none mb-1">
|
<p className="text-[28px] font-bold text-gray-900 tabular-nums leading-none mb-1">
|
||||||
{importResult.updated}
|
{importResult.updated}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-[12px] text-blue-600 font-medium">
|
<p className="text-[12px] text-blue-600 font-medium">Updated</p>
|
||||||
Updated
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
|
@ -856,7 +898,8 @@ function ContactImportPage() {
|
||||||
{importResult.errors.length > 0 && (
|
{importResult.errors.length > 0 && (
|
||||||
<div className="bg-red-50 border border-red-200 rounded-xl p-4">
|
<div className="bg-red-50 border border-red-200 rounded-xl p-4">
|
||||||
<p className="text-[12px] font-semibold text-red-700 mb-3">
|
<p className="text-[12px] font-semibold text-red-700 mb-3">
|
||||||
{importResult.errors.length} row{importResult.errors.length !== 1 ? "s" : ""} failed
|
{importResult.errors.length} row
|
||||||
|
{importResult.errors.length !== 1 ? "s" : ""} failed
|
||||||
</p>
|
</p>
|
||||||
<div className="space-y-1.5 max-h-40 overflow-y-auto">
|
<div className="space-y-1.5 max-h-40 overflow-y-auto">
|
||||||
{importResult.errors.map((e, i) => (
|
{importResult.errors.map((e, i) => (
|
||||||
|
|
|
||||||
|
|
@ -568,7 +568,11 @@ function ContactsPage() {
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
<FieldsDialog appId={appId} open={fieldsOpen} onOpenChange={setFieldsOpen} />
|
<FieldsDialog
|
||||||
|
appId={appId}
|
||||||
|
open={fieldsOpen}
|
||||||
|
onOpenChange={setFieldsOpen}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,9 +15,7 @@ import {
|
||||||
DialogDescription,
|
DialogDescription,
|
||||||
} from "#/components/ui/dialog";
|
} from "#/components/ui/dialog";
|
||||||
|
|
||||||
export const Route = createFileRoute(
|
export const Route = createFileRoute("/apps/$appId/contacts/settings/")({
|
||||||
"/apps/$appId/contacts/settings/",
|
|
||||||
)({
|
|
||||||
component: ContactSettingsPage,
|
component: ContactSettingsPage,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -175,7 +173,9 @@ function ContactSettingsPage() {
|
||||||
<p className="text-sm text-muted-foreground">Loading…</p>
|
<p className="text-sm text-muted-foreground">Loading…</p>
|
||||||
)}
|
)}
|
||||||
{!(isPending as boolean) && schemas.length === 0 && (
|
{!(isPending as boolean) && schemas.length === 0 && (
|
||||||
<p className="text-sm text-muted-foreground">No custom fields defined.</p>
|
<p className="text-sm text-muted-foreground">
|
||||||
|
No custom fields defined.
|
||||||
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|
@ -199,11 +199,7 @@ function ContactSettingsPage() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1 shrink-0">
|
<div className="flex items-center gap-1 shrink-0">
|
||||||
<Button
|
<Button variant="ghost" size="sm" onClick={() => openEdit(s)}>
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => openEdit(s)}
|
|
||||||
>
|
|
||||||
Edit
|
Edit
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -289,7 +285,12 @@ function ContactSettingsPage() {
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
{/* Edit dialog */}
|
{/* Edit dialog */}
|
||||||
<Dialog open={!!editId} onOpenChange={(o) => { if (!o) setEditId(null); }}>
|
<Dialog
|
||||||
|
open={!!editId}
|
||||||
|
onOpenChange={(o) => {
|
||||||
|
if (!o) setEditId(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Edit field</DialogTitle>
|
<DialogTitle>Edit field</DialogTitle>
|
||||||
|
|
@ -316,10 +317,7 @@ function ContactSettingsPage() {
|
||||||
<span className="text-sm">Required field</span>
|
<span className="text-sm">Required field</span>
|
||||||
</label>
|
</label>
|
||||||
<div className="flex gap-2 justify-end">
|
<div className="flex gap-2 justify-end">
|
||||||
<Button
|
<Button variant="outline" onClick={() => setEditId(null)}>
|
||||||
variant="outline"
|
|
||||||
onClick={() => setEditId(null)}
|
|
||||||
>
|
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={saveEdit} disabled={saving}>
|
<Button onClick={saveEdit} disabled={saving}>
|
||||||
|
|
@ -333,7 +331,9 @@ function ContactSettingsPage() {
|
||||||
{/* Delete confirm */}
|
{/* Delete confirm */}
|
||||||
<Dialog
|
<Dialog
|
||||||
open={!!deleteId}
|
open={!!deleteId}
|
||||||
onOpenChange={(o) => { if (!o) setDeleteId(null); }}
|
onOpenChange={(o) => {
|
||||||
|
if (!o) setDeleteId(null);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,16 @@
|
||||||
import { createFileRoute, useRouter } from "@tanstack/react-router";
|
import { createFileRoute, useRouter } from "@tanstack/react-router";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useState, useEffect, useRef } from "react";
|
import { useState, useEffect, useRef } from "react";
|
||||||
import { Key, Plus, Copy, Check, Trash2, AlertTriangle, Shield, RefreshCw } from "lucide-react";
|
import {
|
||||||
|
Key,
|
||||||
|
Plus,
|
||||||
|
Copy,
|
||||||
|
Check,
|
||||||
|
Trash2,
|
||||||
|
AlertTriangle,
|
||||||
|
Shield,
|
||||||
|
RefreshCw,
|
||||||
|
} from "lucide-react";
|
||||||
import { appQuery, apiKeysQuery } from "#/lib/query";
|
import { appQuery, apiKeysQuery } from "#/lib/query";
|
||||||
import { Button } from "#/components/ui/button";
|
import { Button } from "#/components/ui/button";
|
||||||
import { Input } from "#/components/ui/input";
|
import { Input } from "#/components/ui/input";
|
||||||
|
|
@ -319,21 +328,29 @@ function AppKeysPage() {
|
||||||
Rotate encryption key
|
Rotate encryption key
|
||||||
</p>
|
</p>
|
||||||
<p className="text-[12px] text-gray-500 mt-0.5">
|
<p className="text-[12px] text-gray-500 mt-0.5">
|
||||||
Generate a new data encryption key and re-encrypt all contact and session data. This may take a moment.
|
Generate a new data encryption key and re-encrypt all contact
|
||||||
|
and session data. This may take a moment.
|
||||||
</p>
|
</p>
|
||||||
{rotationStatus && (
|
{rotationStatus && (
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
{rotationStatus.status === "pending" || rotationStatus.status === "running" ? (
|
{rotationStatus.status === "pending" ||
|
||||||
|
rotationStatus.status === "running" ? (
|
||||||
<div className="flex items-center gap-2 text-[12px] text-blue-600">
|
<div className="flex items-center gap-2 text-[12px] text-blue-600">
|
||||||
<RefreshCw size={11} className="animate-spin" />
|
<RefreshCw size={11} className="animate-spin" />
|
||||||
Rotation in progress…
|
Rotation in progress…
|
||||||
</div>
|
</div>
|
||||||
) : rotationStatus.status === "done" ? (
|
) : rotationStatus.status === "done" ? (
|
||||||
<p className="text-[12px] text-emerald-600">
|
<p className="text-[12px] text-emerald-600">
|
||||||
Rotation completed{rotationStatus.completedAt ? ` at ${new Date(rotationStatus.completedAt).toLocaleString()}` : ""}.
|
Rotation completed
|
||||||
|
{rotationStatus.completedAt
|
||||||
|
? ` at ${new Date(rotationStatus.completedAt).toLocaleString()}`
|
||||||
|
: ""}
|
||||||
|
.
|
||||||
</p>
|
</p>
|
||||||
) : rotationStatus.status === "failed" ? (
|
) : rotationStatus.status === "failed" ? (
|
||||||
<p className="text-[12px] text-red-600">Rotation failed. Please try again.</p>
|
<p className="text-[12px] text-red-600">
|
||||||
|
Rotation failed. Please try again.
|
||||||
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -463,9 +480,14 @@ function AppKeysPage() {
|
||||||
>
|
>
|
||||||
<DialogContent className="sm:max-w-[400px]">
|
<DialogContent className="sm:max-w-[400px]">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="text-[15px]">Rotate encryption key?</DialogTitle>
|
<DialogTitle className="text-[15px]">
|
||||||
|
Rotate encryption key?
|
||||||
|
</DialogTitle>
|
||||||
<DialogDescription className="text-[13px]">
|
<DialogDescription className="text-[13px]">
|
||||||
This generates a new data encryption key and re-encrypts all contacts and session credentials. The app remains operational. This operation runs in the background and may take a few minutes for large datasets.
|
This generates a new data encryption key and re-encrypts all
|
||||||
|
contacts and session credentials. The app remains operational.
|
||||||
|
This operation runs in the background and may take a few minutes
|
||||||
|
for large datasets.
|
||||||
</DialogDescription>
|
</DialogDescription>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
{rotateError && (
|
{rotateError && (
|
||||||
|
|
|
||||||
|
|
@ -171,209 +171,211 @@ function WebhooksPage() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-8 py-6 max-w-4xl animate-fade-up">
|
<div className="px-8 py-6 max-w-4xl animate-fade-up">
|
||||||
{(isPending as boolean) && (
|
{(isPending as boolean) && (
|
||||||
<p className="text-sm text-muted-foreground">Loading…</p>
|
<p className="text-sm text-muted-foreground">Loading…</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!(isPending as boolean) && webhooks.length === 0 && (
|
{!(isPending as boolean) && webhooks.length === 0 && (
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
No webhook endpoints yet.
|
No webhook endpoints yet.
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{webhooks.map((wh) => (
|
{webhooks.map((wh) => (
|
||||||
<div
|
<div
|
||||||
key={wh.id}
|
key={wh.id}
|
||||||
className="border rounded-lg p-4 flex items-start justify-between gap-4"
|
className="border rounded-lg p-4 flex items-start justify-between gap-4"
|
||||||
>
|
>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
{wh.enabled ? (
|
{wh.enabled ? (
|
||||||
<CheckCircle2 className="h-4 w-4 text-emerald-600 shrink-0" />
|
<CheckCircle2 className="h-4 w-4 text-emerald-600 shrink-0" />
|
||||||
) : (
|
) : (
|
||||||
<XCircle className="h-4 w-4 text-red-500 shrink-0" />
|
<XCircle className="h-4 w-4 text-red-500 shrink-0" />
|
||||||
)}
|
)}
|
||||||
<span className="font-mono text-sm truncate">{wh.url}</span>
|
<span className="font-mono text-sm truncate">{wh.url}</span>
|
||||||
{wh.unhealthySince && (
|
{wh.unhealthySince && (
|
||||||
<Badge variant="destructive" className="shrink-0">
|
<Badge variant="destructive" className="shrink-0">
|
||||||
Unhealthy
|
Unhealthy
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-1 mt-1">
|
|
||||||
{(wh.events.length === 0 ? ["all events"] : wh.events).map(
|
|
||||||
(ev) => (
|
|
||||||
<Badge key={ev} variant="secondary" className="text-xs">
|
|
||||||
{ev}
|
|
||||||
</Badge>
|
</Badge>
|
||||||
),
|
)}
|
||||||
)}
|
</div>
|
||||||
</div>
|
<div className="flex flex-wrap gap-1 mt-1">
|
||||||
{wh.consecutiveFailures > 0 && (
|
{(wh.events.length === 0 ? ["all events"] : wh.events).map(
|
||||||
<p className="text-xs text-red-500 mt-1">
|
(ev) => (
|
||||||
{wh.consecutiveFailures} consecutive failure(s)
|
<Badge key={ev} variant="secondary" className="text-xs">
|
||||||
</p>
|
{ev}
|
||||||
)}
|
</Badge>
|
||||||
</div>
|
),
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
)}
|
||||||
<Button
|
</div>
|
||||||
variant="outline"
|
{wh.consecutiveFailures > 0 && (
|
||||||
size="sm"
|
<p className="text-xs text-red-500 mt-1">
|
||||||
disabled={testingId === wh.id}
|
{wh.consecutiveFailures} consecutive failure(s)
|
||||||
onClick={() => testWebhook(wh.id)}
|
|
||||||
>
|
|
||||||
<TestTube className="h-3 w-3 mr-1" />
|
|
||||||
{testingId === wh.id ? "Testing…" : "Test"}
|
|
||||||
</Button>
|
|
||||||
<Link
|
|
||||||
to="/apps/$appId/webhooks/$webhookId/deliveries"
|
|
||||||
params={{ appId, webhookId: wh.id }}
|
|
||||||
>
|
|
||||||
<Button variant="outline" size="sm">
|
|
||||||
Log
|
|
||||||
</Button>
|
|
||||||
</Link>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setDeleteId(wh.id)}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4 text-red-500" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{testResult && (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"mt-4 p-3 rounded-lg text-sm border",
|
|
||||||
testResult.ok
|
|
||||||
? "border-emerald-200 bg-emerald-50 text-emerald-800"
|
|
||||||
: "border-red-200 bg-red-50 text-red-800",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{testResult.ok ? (
|
|
||||||
<>Test delivered — HTTP {testResult.httpStatus}</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
Test failed —{" "}
|
|
||||||
{testResult.error ?? `HTTP ${testResult.httpStatus}`}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Create dialog */}
|
|
||||||
<Dialog
|
|
||||||
open={newOpen}
|
|
||||||
onOpenChange={(o) => {
|
|
||||||
setNewOpen(o);
|
|
||||||
if (!o) setCreatedSecret(null);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DialogContent className="max-w-lg">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Add webhook endpoint</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Events will be delivered via signed HTTP POST.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
{createdSecret ? (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<p className="text-sm text-emerald-700 font-medium">
|
|
||||||
Endpoint created. Copy your signing secret — it won't be shown
|
|
||||||
again.
|
|
||||||
</p>
|
|
||||||
<code className="block bg-muted rounded p-3 text-xs break-all select-all">
|
|
||||||
{createdSecret}
|
|
||||||
</code>
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
setNewOpen(false);
|
|
||||||
setCreatedSecret(null);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Done
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<form onSubmit={createWebhook} className="space-y-4">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label>URL</Label>
|
|
||||||
<Input
|
|
||||||
placeholder="https://example.com/webhook"
|
|
||||||
value={newUrl}
|
|
||||||
onChange={(e) => setNewUrl(e.target.value)}
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
{newUrl.startsWith("http://") && !newUrl.startsWith("https://") && (
|
|
||||||
<p className="text-xs text-amber-600 bg-amber-50 border border-amber-200 rounded px-2 py-1 mt-1">
|
|
||||||
Warning: HTTP endpoints receive webhook payloads unencrypted. Use HTTPS in production.
|
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
<Label>Events (empty = all)</Label>
|
<Button
|
||||||
<div className="flex flex-wrap gap-2 mt-1">
|
variant="outline"
|
||||||
{ALL_EVENTS.map((ev) => (
|
size="sm"
|
||||||
<button
|
disabled={testingId === wh.id}
|
||||||
key={ev}
|
onClick={() => testWebhook(wh.id)}
|
||||||
type="button"
|
>
|
||||||
onClick={() => toggleEvent(ev)}
|
<TestTube className="h-3 w-3 mr-1" />
|
||||||
className={cn(
|
{testingId === wh.id ? "Testing…" : "Test"}
|
||||||
"text-xs px-2 py-1 rounded border transition-colors",
|
</Button>
|
||||||
newEvents.includes(ev)
|
<Link
|
||||||
? "bg-primary text-primary-foreground border-primary"
|
to="/apps/$appId/webhooks/$webhookId/deliveries"
|
||||||
: "border-border hover:bg-muted",
|
params={{ appId, webhookId: wh.id }}
|
||||||
)}
|
>
|
||||||
>
|
<Button variant="outline" size="sm">
|
||||||
{ev}
|
Log
|
||||||
</button>
|
</Button>
|
||||||
))}
|
</Link>
|
||||||
</div>
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setDeleteId(wh.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 text-red-500" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{createError && (
|
</div>
|
||||||
<p className="text-sm text-red-500">{createError}</p>
|
))}
|
||||||
)}
|
</div>
|
||||||
<Button type="submit" disabled={creating}>
|
|
||||||
{creating ? "Creating…" : "Create"}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
)}
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
|
|
||||||
{/* Delete confirm */}
|
{testResult && (
|
||||||
<Dialog
|
<div
|
||||||
open={!!deleteId}
|
className={cn(
|
||||||
onOpenChange={(o) => {
|
"mt-4 p-3 rounded-lg text-sm border",
|
||||||
if (!o) setDeleteId(null);
|
testResult.ok
|
||||||
}}
|
? "border-emerald-200 bg-emerald-50 text-emerald-800"
|
||||||
>
|
: "border-red-200 bg-red-50 text-red-800",
|
||||||
<DialogContent>
|
)}
|
||||||
<DialogHeader>
|
>
|
||||||
<DialogTitle>Delete endpoint</DialogTitle>
|
{testResult.ok ? (
|
||||||
<DialogDescription>
|
<>Test delivered — HTTP {testResult.httpStatus}</>
|
||||||
This removes the endpoint and all delivery history.
|
) : (
|
||||||
</DialogDescription>
|
<>
|
||||||
</DialogHeader>
|
Test failed —{" "}
|
||||||
<div className="flex gap-2 justify-end">
|
{testResult.error ?? `HTTP ${testResult.httpStatus}`}
|
||||||
<Button variant="outline" onClick={() => setDeleteId(null)}>
|
</>
|
||||||
Cancel
|
)}
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
disabled={deleting}
|
|
||||||
onClick={deleteWebhook}
|
|
||||||
>
|
|
||||||
{deleting ? "Deleting…" : "Delete"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
)}
|
||||||
</Dialog>
|
|
||||||
|
{/* Create dialog */}
|
||||||
|
<Dialog
|
||||||
|
open={newOpen}
|
||||||
|
onOpenChange={(o) => {
|
||||||
|
setNewOpen(o);
|
||||||
|
if (!o) setCreatedSecret(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent className="max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Add webhook endpoint</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Events will be delivered via signed HTTP POST.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
{createdSecret ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-sm text-emerald-700 font-medium">
|
||||||
|
Endpoint created. Copy your signing secret — it won't be shown
|
||||||
|
again.
|
||||||
|
</p>
|
||||||
|
<code className="block bg-muted rounded p-3 text-xs break-all select-all">
|
||||||
|
{createdSecret}
|
||||||
|
</code>
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
setNewOpen(false);
|
||||||
|
setCreatedSecret(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Done
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={createWebhook} className="space-y-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>URL</Label>
|
||||||
|
<Input
|
||||||
|
placeholder="https://example.com/webhook"
|
||||||
|
value={newUrl}
|
||||||
|
onChange={(e) => setNewUrl(e.target.value)}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
{newUrl.startsWith("http://") &&
|
||||||
|
!newUrl.startsWith("https://") && (
|
||||||
|
<p className="text-xs text-amber-600 bg-amber-50 border border-amber-200 rounded px-2 py-1 mt-1">
|
||||||
|
Warning: HTTP endpoints receive webhook payloads
|
||||||
|
unencrypted. Use HTTPS in production.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label>Events (empty = all)</Label>
|
||||||
|
<div className="flex flex-wrap gap-2 mt-1">
|
||||||
|
{ALL_EVENTS.map((ev) => (
|
||||||
|
<button
|
||||||
|
key={ev}
|
||||||
|
type="button"
|
||||||
|
onClick={() => toggleEvent(ev)}
|
||||||
|
className={cn(
|
||||||
|
"text-xs px-2 py-1 rounded border transition-colors",
|
||||||
|
newEvents.includes(ev)
|
||||||
|
? "bg-primary text-primary-foreground border-primary"
|
||||||
|
: "border-border hover:bg-muted",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{ev}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{createError && (
|
||||||
|
<p className="text-sm text-red-500">{createError}</p>
|
||||||
|
)}
|
||||||
|
<Button type="submit" disabled={creating}>
|
||||||
|
{creating ? "Creating…" : "Create"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Delete confirm */}
|
||||||
|
<Dialog
|
||||||
|
open={!!deleteId}
|
||||||
|
onOpenChange={(o) => {
|
||||||
|
if (!o) setDeleteId(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Delete endpoint</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
This removes the endpoint and all delivery history.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
<Button variant="outline" onClick={() => setDeleteId(null)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
disabled={deleting}
|
||||||
|
onClick={deleteWebhook}
|
||||||
|
>
|
||||||
|
{deleting ? "Deleting…" : "Delete"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -617,7 +617,10 @@ describe("custom field schema validation — AC6", () => {
|
||||||
|
|
||||||
// Permissive coercion — production allows these
|
// Permissive coercion — production allows these
|
||||||
it("numeric string passes for number field (coercible)", () => {
|
it("numeric string passes for number field (coercible)", () => {
|
||||||
const { valid } = validateCustomFields({ tier: "gold", score: "42" }, schemas);
|
const { valid } = validateCustomFields(
|
||||||
|
{ tier: "gold", score: "42" },
|
||||||
|
schemas,
|
||||||
|
);
|
||||||
expect(valid).toBe(true);
|
expect(valid).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -631,12 +634,18 @@ describe("custom field schema validation — AC6", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('"true" string passes for bool field', () => {
|
it('"true" string passes for bool field', () => {
|
||||||
const { valid } = validateCustomFields({ tier: "gold", active: "true" }, schemas);
|
const { valid } = validateCustomFields(
|
||||||
|
{ tier: "gold", active: "true" },
|
||||||
|
schemas,
|
||||||
|
);
|
||||||
expect(valid).toBe(true);
|
expect(valid).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('"false" string passes for bool field', () => {
|
it('"false" string passes for bool field', () => {
|
||||||
const { valid } = validateCustomFields({ tier: "gold", active: "false" }, schemas);
|
const { valid } = validateCustomFields(
|
||||||
|
{ tier: "gold", active: "false" },
|
||||||
|
schemas,
|
||||||
|
);
|
||||||
expect(valid).toBe(true);
|
expect(valid).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,8 @@ function verifySignature(
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("HMAC webhook signing (FR-W3)", () => {
|
describe("HMAC webhook signing (FR-W3)", () => {
|
||||||
const SECRET = "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890";
|
const SECRET =
|
||||||
|
"abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890";
|
||||||
const BODY = JSON.stringify({ event: "message.received", data: { id: "x" } });
|
const BODY = JSON.stringify({ event: "message.received", data: { id: "x" } });
|
||||||
const TS = "1747382400000";
|
const TS = "1747382400000";
|
||||||
|
|
||||||
|
|
@ -53,11 +54,14 @@ describe("HMAC webhook signing (FR-W3)", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("different timestamp produces different signature", () => {
|
it("different timestamp produces different signature", () => {
|
||||||
expect(sign(BODY, TS, SECRET)).not.toBe(sign(BODY, "9999999999999", SECRET));
|
expect(sign(BODY, TS, SECRET)).not.toBe(
|
||||||
|
sign(BODY, "9999999999999", SECRET),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("different secret produces different signature", () => {
|
it("different secret produces different signature", () => {
|
||||||
const other = "0000000000000000000000000000000000000000000000000000000000000000";
|
const other =
|
||||||
|
"0000000000000000000000000000000000000000000000000000000000000000";
|
||||||
expect(sign(BODY, TS, SECRET)).not.toBe(sign(BODY, TS, other));
|
expect(sign(BODY, TS, SECRET)).not.toBe(sign(BODY, TS, other));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -88,7 +92,9 @@ describe("HMAC webhook signing (FR-W3)", () => {
|
||||||
|
|
||||||
// ─── Retry delay schedule (FR-W4) ────────────────────────────────────────────
|
// ─── Retry delay schedule (FR-W4) ────────────────────────────────────────────
|
||||||
|
|
||||||
const RETRY_DELAYS = [0, 30_000, 120_000, 600_000, 3_600_000, 21_600_000, 86_400_000];
|
const RETRY_DELAYS = [
|
||||||
|
0, 30_000, 120_000, 600_000, 3_600_000, 21_600_000, 86_400_000,
|
||||||
|
];
|
||||||
|
|
||||||
function nextDelay(attempt: number): number | undefined {
|
function nextDelay(attempt: number): number | undefined {
|
||||||
return (RETRY_DELAYS as (number | undefined)[])[attempt];
|
return (RETRY_DELAYS as (number | undefined)[])[attempt];
|
||||||
|
|
@ -175,7 +181,12 @@ function applySuccess(endpoint: FakeEndpoint): void {
|
||||||
|
|
||||||
describe("consecutive-failure unhealthy threshold", () => {
|
describe("consecutive-failure unhealthy threshold", () => {
|
||||||
function makeEndpoint(): FakeEndpoint {
|
function makeEndpoint(): FakeEndpoint {
|
||||||
return { id: "ep1", consecutiveFailures: 0, unhealthySince: null, enabled: true };
|
return {
|
||||||
|
id: "ep1",
|
||||||
|
consecutiveFailures: 0,
|
||||||
|
unhealthySince: null,
|
||||||
|
enabled: true,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
it("49 failures → still healthy, enabled", () => {
|
it("49 failures → still healthy, enabled", () => {
|
||||||
|
|
@ -246,7 +257,10 @@ describe("delivery status after attempt", () => {
|
||||||
|
|
||||||
// ─── Endpoint event-filter matching ──────────────────────────────────────────
|
// ─── Endpoint event-filter matching ──────────────────────────────────────────
|
||||||
|
|
||||||
function endpointMatchesEvent(endpointEvents: string[], eventType: string): boolean {
|
function endpointMatchesEvent(
|
||||||
|
endpointEvents: string[],
|
||||||
|
eventType: string,
|
||||||
|
): boolean {
|
||||||
if (endpointEvents.length === 0) return true; // empty = all events
|
if (endpointEvents.length === 0) return true; // empty = all events
|
||||||
return endpointEvents.includes(eventType) || endpointEvents.includes("*");
|
return endpointEvents.includes(eventType) || endpointEvents.includes("*");
|
||||||
}
|
}
|
||||||
|
|
@ -258,11 +272,15 @@ describe("endpoint event-filter matching", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("exact match", () => {
|
it("exact match", () => {
|
||||||
expect(endpointMatchesEvent(["message.received"], "message.received")).toBe(true);
|
expect(endpointMatchesEvent(["message.received"], "message.received")).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("no match", () => {
|
it("no match", () => {
|
||||||
expect(endpointMatchesEvent(["message.received"], "message.sent")).toBe(false);
|
expect(endpointMatchesEvent(["message.received"], "message.sent")).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("wildcard * matches any event", () => {
|
it("wildcard * matches any event", () => {
|
||||||
|
|
@ -270,19 +288,35 @@ describe("endpoint event-filter matching", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it("multiple specific events: matches first", () => {
|
it("multiple specific events: matches first", () => {
|
||||||
expect(endpointMatchesEvent(["message.received", "message.sent"], "message.sent")).toBe(true);
|
expect(
|
||||||
|
endpointMatchesEvent(
|
||||||
|
["message.received", "message.sent"],
|
||||||
|
"message.sent",
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("multiple specific events: no match for unlisted", () => {
|
it("multiple specific events: no match for unlisted", () => {
|
||||||
expect(endpointMatchesEvent(["message.received", "message.sent"], "contact.created")).toBe(false);
|
expect(
|
||||||
|
endpointMatchesEvent(
|
||||||
|
["message.received", "message.sent"],
|
||||||
|
"contact.created",
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("all FR-W2 event types recognized by wildcard", () => {
|
it("all FR-W2 event types recognized by wildcard", () => {
|
||||||
const allEvents = [
|
const allEvents = [
|
||||||
"session.status", "session.deleted",
|
"session.status",
|
||||||
"message.received", "message.sent", "message.delivered", "message.read", "message.failed",
|
"session.deleted",
|
||||||
|
"message.received",
|
||||||
|
"message.sent",
|
||||||
|
"message.delivered",
|
||||||
|
"message.read",
|
||||||
|
"message.failed",
|
||||||
"presence.update",
|
"presence.update",
|
||||||
"contact.created", "contact.updated",
|
"contact.created",
|
||||||
|
"contact.updated",
|
||||||
];
|
];
|
||||||
for (const evt of allEvents) {
|
for (const evt of allEvents) {
|
||||||
expect(endpointMatchesEvent(["*"], evt)).toBe(true);
|
expect(endpointMatchesEvent(["*"], evt)).toBe(true);
|
||||||
|
|
@ -316,7 +350,12 @@ function enrichPayload(
|
||||||
|
|
||||||
describe("payload enrichment", () => {
|
describe("payload enrichment", () => {
|
||||||
it("always includes event, event_id, app_id, timestamp", () => {
|
it("always includes event, event_id, app_id, timestamp", () => {
|
||||||
const p = enrichPayload({ data: 1 }, "message.received", "evt-123", "app-abc");
|
const p = enrichPayload(
|
||||||
|
{ data: 1 },
|
||||||
|
"message.received",
|
||||||
|
"evt-123",
|
||||||
|
"app-abc",
|
||||||
|
);
|
||||||
expect(p.event).toBe("message.received");
|
expect(p.event).toBe("message.received");
|
||||||
expect(p.event_id).toBe("evt-123");
|
expect(p.event_id).toBe("evt-123");
|
||||||
expect(p.app_id).toBe("app-abc");
|
expect(p.app_id).toBe("app-abc");
|
||||||
|
|
@ -423,14 +462,22 @@ describe("delivery log retention (pruneDeliveries)", () => {
|
||||||
expect(forEp).toHaveLength(10);
|
expect(forEp).toHaveLength(10);
|
||||||
// Newest 10 kept (indices 5-14)
|
// Newest 10 kept (indices 5-14)
|
||||||
const ids = forEp.map((x) => x.id).sort();
|
const ids = forEp.map((x) => x.id).sort();
|
||||||
expect(ids).toEqual(["d10", "d11", "d12", "d13", "d14", "d5", "d6", "d7", "d8", "d9"]);
|
expect(ids).toEqual([
|
||||||
|
"d10",
|
||||||
|
"d11",
|
||||||
|
"d12",
|
||||||
|
"d13",
|
||||||
|
"d14",
|
||||||
|
"d5",
|
||||||
|
"d6",
|
||||||
|
"d7",
|
||||||
|
"d8",
|
||||||
|
"d9",
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("other endpoints not affected by pruning", () => {
|
it("other endpoints not affected by pruning", () => {
|
||||||
const d = [
|
const d = [...makeDeliveries(15, "ep1"), ...makeDeliveries(5, "ep2")];
|
||||||
...makeDeliveries(15, "ep1"),
|
|
||||||
...makeDeliveries(5, "ep2"),
|
|
||||||
];
|
|
||||||
const result = pruneDeliveries(d, "ep1", RETAIN);
|
const result = pruneDeliveries(d, "ep1", RETAIN);
|
||||||
const ep2 = result.filter((x) => x.endpointId === "ep2");
|
const ep2 = result.filter((x) => x.endpointId === "ep2");
|
||||||
expect(ep2).toHaveLength(5);
|
expect(ep2).toHaveLength(5);
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue