refactor: code review improvements
This commit is contained in:
parent
b40333a2cd
commit
79520fe28f
32 changed files with 3146 additions and 799 deletions
|
|
@ -42,7 +42,7 @@ docker compose up -d
|
|||
## Architecture
|
||||
|
||||
| Layer | Technology |
|
||||
|-------|-----------|
|
||||
| ----------------- | ---------------------------------------------- |
|
||||
| Frontend + SSR | TanStack Start (React, Vite) |
|
||||
| Routing | TanStack Router (file-based) |
|
||||
| API | TanStack Start server handlers (Nitro adapter) |
|
||||
|
|
@ -57,7 +57,7 @@ docker compose up -d
|
|||
## Documentation
|
||||
|
||||
| File | Contents |
|
||||
|------|----------|
|
||||
| -------------------------------------------- | ----------------------------------------- |
|
||||
| [docs/quickstart.md](docs/quickstart.md) | Step-by-step getting started guide |
|
||||
| [docs/api.md](docs/api.md) | REST endpoint reference |
|
||||
| [docs/webhooks.md](docs/webhooks.md) | Event catalog, HMAC verification |
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ Risk factors that increase ban likelihood:
|
|||
New numbers should be introduced to automation gradually:
|
||||
|
||||
| Week | Max messages/day | Notes |
|
||||
|------|-----------------|-------|
|
||||
| ---- | ---------------- | ----------------------------- |
|
||||
| 1 | 50 | Personal-style messages only |
|
||||
| 2 | 100 | Continue conversational style |
|
||||
| 3 | 200 | Can begin light business use |
|
||||
|
|
|
|||
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",
|
||||
"tableFrom": "api_keys",
|
||||
"tableTo": "apps",
|
||||
"columnsFrom": [
|
||||
"app_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"columnsFrom": ["app_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
|
|
@ -172,12 +168,8 @@
|
|||
"name": "apps_owner_user_id_users_id_fk",
|
||||
"tableFrom": "apps",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"owner_user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"columnsFrom": ["owner_user_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
|
|
@ -345,12 +337,8 @@
|
|||
"name": "contact_group_members_contact_id_contacts_id_fk",
|
||||
"tableFrom": "contact_group_members",
|
||||
"tableTo": "contacts",
|
||||
"columnsFrom": [
|
||||
"contact_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"columnsFrom": ["contact_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
|
|
@ -358,12 +346,8 @@
|
|||
"name": "contact_group_members_group_id_contact_groups_id_fk",
|
||||
"tableFrom": "contact_group_members",
|
||||
"tableTo": "contact_groups",
|
||||
"columnsFrom": [
|
||||
"group_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"columnsFrom": ["group_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
|
|
@ -371,10 +355,7 @@
|
|||
"compositePrimaryKeys": {
|
||||
"contact_group_members_contact_id_group_id_pk": {
|
||||
"name": "contact_group_members_contact_id_group_id_pk",
|
||||
"columns": [
|
||||
"contact_id",
|
||||
"group_id"
|
||||
]
|
||||
"columns": ["contact_id", "group_id"]
|
||||
}
|
||||
},
|
||||
"uniqueConstraints": {},
|
||||
|
|
@ -441,12 +422,8 @@
|
|||
"name": "contact_groups_app_id_apps_id_fk",
|
||||
"tableFrom": "contact_groups",
|
||||
"tableTo": "apps",
|
||||
"columnsFrom": [
|
||||
"app_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"columnsFrom": ["app_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
|
|
@ -509,12 +486,8 @@
|
|||
"name": "contact_tags_app_id_apps_id_fk",
|
||||
"tableFrom": "contact_tags",
|
||||
"tableTo": "apps",
|
||||
"columnsFrom": [
|
||||
"app_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"columnsFrom": ["app_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
|
|
@ -522,12 +495,8 @@
|
|||
"name": "contact_tags_contact_id_contacts_id_fk",
|
||||
"tableFrom": "contact_tags",
|
||||
"tableTo": "contacts",
|
||||
"columnsFrom": [
|
||||
"contact_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"columnsFrom": ["contact_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
|
|
@ -537,10 +506,7 @@
|
|||
"contact_tags_unique": {
|
||||
"name": "contact_tags_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"contact_id",
|
||||
"tag"
|
||||
]
|
||||
"columns": ["contact_id", "tag"]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
|
|
@ -672,12 +638,8 @@
|
|||
"name": "contacts_app_id_apps_id_fk",
|
||||
"tableFrom": "contacts",
|
||||
"tableTo": "apps",
|
||||
"columnsFrom": [
|
||||
"app_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"columnsFrom": ["app_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
|
|
@ -687,10 +649,7 @@
|
|||
"contacts_app_phone_unique": {
|
||||
"name": "contacts_app_phone_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"app_id",
|
||||
"phone_blind_index"
|
||||
]
|
||||
"columns": ["app_id", "phone_blind_index"]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
|
|
@ -771,12 +730,8 @@
|
|||
"name": "custom_field_schemas_app_id_apps_id_fk",
|
||||
"tableFrom": "custom_field_schemas",
|
||||
"tableTo": "apps",
|
||||
"columnsFrom": [
|
||||
"app_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"columnsFrom": ["app_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
|
|
@ -786,10 +741,7 @@
|
|||
"custom_field_schemas_app_key_unique": {
|
||||
"name": "custom_field_schemas_app_key_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"app_id",
|
||||
"key"
|
||||
]
|
||||
"columns": ["app_id", "key"]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
|
|
@ -984,12 +936,8 @@
|
|||
"name": "messages_app_id_apps_id_fk",
|
||||
"tableFrom": "messages",
|
||||
"tableTo": "apps",
|
||||
"columnsFrom": [
|
||||
"app_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"columnsFrom": ["app_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
|
|
@ -997,12 +945,8 @@
|
|||
"name": "messages_session_id_sessions_id_fk",
|
||||
"tableFrom": "messages",
|
||||
"tableTo": "sessions",
|
||||
"columnsFrom": [
|
||||
"session_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"columnsFrom": ["session_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
|
|
@ -1062,12 +1006,8 @@
|
|||
"name": "session_keys_session_id_sessions_id_fk",
|
||||
"tableFrom": "session_keys",
|
||||
"tableTo": "sessions",
|
||||
"columnsFrom": [
|
||||
"session_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"columnsFrom": ["session_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
|
|
@ -1077,11 +1017,7 @@
|
|||
"session_keys_unique": {
|
||||
"name": "session_keys_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"session_id",
|
||||
"key_type",
|
||||
"key_id"
|
||||
]
|
||||
"columns": ["session_id", "key_type", "key_id"]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
|
|
@ -1196,12 +1132,8 @@
|
|||
"name": "sessions_app_id_apps_id_fk",
|
||||
"tableFrom": "sessions",
|
||||
"tableTo": "apps",
|
||||
"columnsFrom": [
|
||||
"app_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"columnsFrom": ["app_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
|
|
@ -1269,9 +1201,7 @@
|
|||
"users_email_unique": {
|
||||
"name": "users_email_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"email"
|
||||
]
|
||||
"columns": ["email"]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
|
|
@ -1403,12 +1333,8 @@
|
|||
"name": "webhook_deliveries_endpoint_id_webhook_endpoints_id_fk",
|
||||
"tableFrom": "webhook_deliveries",
|
||||
"tableTo": "webhook_endpoints",
|
||||
"columnsFrom": [
|
||||
"endpoint_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"columnsFrom": ["endpoint_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
},
|
||||
|
|
@ -1416,12 +1342,8 @@
|
|||
"name": "webhook_deliveries_app_id_apps_id_fk",
|
||||
"tableFrom": "webhook_deliveries",
|
||||
"tableTo": "apps",
|
||||
"columnsFrom": [
|
||||
"app_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"columnsFrom": ["app_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
|
|
@ -1518,12 +1440,8 @@
|
|||
"name": "webhook_endpoints_app_id_apps_id_fk",
|
||||
"tableFrom": "webhook_endpoints",
|
||||
"tableTo": "apps",
|
||||
"columnsFrom": [
|
||||
"app_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"columnsFrom": ["app_id"],
|
||||
"columnsTo": ["id"],
|
||||
"onDelete": "no action",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
|
|
@ -1539,59 +1457,32 @@
|
|||
"public.actor_type": {
|
||||
"name": "actor_type",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"user",
|
||||
"api_key"
|
||||
]
|
||||
"values": ["user", "api_key"]
|
||||
},
|
||||
"public.contact_source": {
|
||||
"name": "contact_source",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"csv",
|
||||
"whatsapp_sync",
|
||||
"api"
|
||||
]
|
||||
"values": ["csv", "whatsapp_sync", "api"]
|
||||
},
|
||||
"public.custom_field_type": {
|
||||
"name": "custom_field_type",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"text",
|
||||
"number",
|
||||
"bool",
|
||||
"date"
|
||||
]
|
||||
"values": ["text", "number", "bool", "date"]
|
||||
},
|
||||
"public.message_direction": {
|
||||
"name": "message_direction",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"in",
|
||||
"out"
|
||||
]
|
||||
"values": ["in", "out"]
|
||||
},
|
||||
"public.message_status": {
|
||||
"name": "message_status",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"queued",
|
||||
"sent",
|
||||
"delivered",
|
||||
"read",
|
||||
"failed"
|
||||
]
|
||||
"values": ["queued", "sent", "delivered", "read", "failed"]
|
||||
},
|
||||
"public.message_type": {
|
||||
"name": "message_type",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"text",
|
||||
"image",
|
||||
"video",
|
||||
"document",
|
||||
"audio"
|
||||
]
|
||||
"values": ["text", "image", "video", "document", "audio"]
|
||||
},
|
||||
"public.session_status": {
|
||||
"name": "session_status",
|
||||
|
|
@ -1609,11 +1500,7 @@
|
|||
"public.webhook_delivery_status": {
|
||||
"name": "webhook_delivery_status",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"pending",
|
||||
"success",
|
||||
"failed"
|
||||
]
|
||||
"values": ["pending", "success", "failed"]
|
||||
}
|
||||
},
|
||||
"schemas": {},
|
||||
|
|
|
|||
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,
|
||||
"tag": "0006_tricky_korg",
|
||||
"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": {
|
||||
"@aws-sdk/client-s3": "^3.1047.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.1047.0",
|
||||
"@faker-js/faker": "^10.3.0",
|
||||
"@opentelemetry/api": "^1.9.1",
|
||||
"@opentelemetry/auto-instrumentations-node": "^0.76.0",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "^0.218.0",
|
||||
"@opentelemetry/sdk-node": "^0.218.0",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@tanstack/match-sorter-utils": "latest",
|
||||
"@tanstack/react-devtools": "latest",
|
||||
"@tanstack/react-query": "^5.100.10",
|
||||
"@tanstack/react-router": "latest",
|
||||
"@tanstack/react-router-devtools": "latest",
|
||||
"@tanstack/react-router-ssr-query": "latest",
|
||||
"@tanstack/react-start": "latest",
|
||||
"@tanstack/react-table": "latest",
|
||||
|
|
@ -64,9 +61,12 @@
|
|||
"uuid": "^14.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@faker-js/faker": "^10.3.0",
|
||||
"@playwright/test": "^1.60.0",
|
||||
"@tailwindcss/typography": "^0.5.16",
|
||||
"@tanstack/devtools-vite": "latest",
|
||||
"@tanstack/react-devtools": "latest",
|
||||
"@tanstack/react-router-devtools": "latest",
|
||||
"@tanstack/eslint-config": "latest",
|
||||
"@testing-library/dom": "^10.4.1",
|
||||
"@testing-library/react": "^16.3.0",
|
||||
|
|
|
|||
|
|
@ -48,7 +48,9 @@ export function FieldsDialog({ appId, open, onOpenChange }: Props) {
|
|||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [newKey, setNewKey] = 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 [creating, setCreating] = useState(false);
|
||||
const [createError, setCreateError] = useState("");
|
||||
|
|
@ -102,7 +104,9 @@ export function FieldsDialog({ appId, open, onOpenChange }: Props) {
|
|||
setNewLabel("");
|
||||
setNewType("text");
|
||||
setNewRequired(false);
|
||||
queryClient.invalidateQueries({ queryKey: ["custom-field-schemas", appId] });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["custom-field-schemas", appId],
|
||||
});
|
||||
} catch (err) {
|
||||
setCreateError(err instanceof Error ? err.message : "Failed");
|
||||
} finally {
|
||||
|
|
@ -118,10 +122,16 @@ export function FieldsDialog({ appId, open, onOpenChange }: Props) {
|
|||
method: "PATCH",
|
||||
headers: { "content-type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ app_id: appId, label: editLabel, required: editRequired }),
|
||||
body: JSON.stringify({
|
||||
app_id: appId,
|
||||
label: editLabel,
|
||||
required: editRequired,
|
||||
}),
|
||||
});
|
||||
setEditId(null);
|
||||
queryClient.invalidateQueries({ queryKey: ["custom-field-schemas", appId] });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["custom-field-schemas", appId],
|
||||
});
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
|
|
@ -135,7 +145,9 @@ export function FieldsDialog({ appId, open, onOpenChange }: Props) {
|
|||
`/api/v1/admin/custom-field-schemas/${deleteId}?app_id=${encodeURIComponent(appId)}`,
|
||||
{ method: "DELETE", credentials: "include" },
|
||||
);
|
||||
queryClient.invalidateQueries({ queryKey: ["custom-field-schemas", appId] });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ["custom-field-schemas", appId],
|
||||
});
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
setDeleteId(null);
|
||||
|
|
@ -156,7 +168,9 @@ export function FieldsDialog({ appId, open, onOpenChange }: Props) {
|
|||
>
|
||||
<DialogContent className="sm:max-w-[520px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-[15px]">Custom field schemas</DialogTitle>
|
||||
<DialogTitle className="text-[15px]">
|
||||
Custom field schemas
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-[13px]">
|
||||
Define custom fields stored on contacts in this app.
|
||||
</DialogDescription>
|
||||
|
|
@ -167,11 +181,16 @@ export function FieldsDialog({ appId, open, onOpenChange }: Props) {
|
|||
<p className="text-[13px] text-gray-400 py-2">Loading…</p>
|
||||
)}
|
||||
{!(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) => (
|
||||
<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 */}
|
||||
<div className="flex items-center justify-between gap-3 px-3 py-2.5">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
|
|
@ -200,7 +219,10 @@ export function FieldsDialog({ appId, open, onOpenChange }: Props) {
|
|||
Edit
|
||||
</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"
|
||||
>
|
||||
<Trash2 size={13} strokeWidth={1.75} />
|
||||
|
|
@ -229,7 +251,9 @@ export function FieldsDialog({ appId, open, onOpenChange }: Props) {
|
|||
onChange={(e) => setEditRequired(e.target.checked)}
|
||||
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>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
|
|
@ -258,7 +282,8 @@ export function FieldsDialog({ appId, open, onOpenChange }: Props) {
|
|||
{deleteId === s.id && (
|
||||
<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">
|
||||
Delete <strong>{s.label}</strong>? Existing data is not removed.
|
||||
Delete <strong>{s.label}</strong>? Existing data is not
|
||||
removed.
|
||||
</p>
|
||||
<div className="flex gap-2 shrink-0 ml-3">
|
||||
<Button
|
||||
|
|
@ -287,7 +312,9 @@ export function FieldsDialog({ appId, open, onOpenChange }: Props) {
|
|||
{/* inline create form */}
|
||||
{showCreate && (
|
||||
<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">
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
|
|
@ -297,12 +324,16 @@ export function FieldsDialog({ appId, open, onOpenChange }: Props) {
|
|||
placeholder="e.g. tier"
|
||||
value={newKey}
|
||||
onChange={(e) =>
|
||||
setNewKey(e.target.value.replace(/\s+/g, "_").toLowerCase())
|
||||
setNewKey(
|
||||
e.target.value.replace(/\s+/g, "_").toLowerCase(),
|
||||
)
|
||||
}
|
||||
className="h-8 text-[13px]"
|
||||
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 className="space-y-1">
|
||||
<Label className="text-[12px] text-gray-600">Label</Label>
|
||||
|
|
@ -320,7 +351,9 @@ export function FieldsDialog({ appId, open, onOpenChange }: Props) {
|
|||
<select
|
||||
value={newType}
|
||||
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"
|
||||
>
|
||||
|
|
@ -337,7 +370,9 @@ export function FieldsDialog({ appId, open, onOpenChange }: Props) {
|
|||
onChange={(e) => setNewRequired(e.target.checked)}
|
||||
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>
|
||||
{createError && (
|
||||
<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"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => { setShowCreate(false); setCreateError(""); }}
|
||||
onClick={() => {
|
||||
setShowCreate(false);
|
||||
setCreateError("");
|
||||
}}
|
||||
className="h-7 px-3 text-[12px]"
|
||||
>
|
||||
Cancel
|
||||
|
|
@ -370,7 +408,11 @@ export function FieldsDialog({ appId, open, onOpenChange }: Props) {
|
|||
|
||||
{!showCreate && (
|
||||
<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"
|
||||
>
|
||||
<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 rotationStatusEnum = pgEnum("rotation_status", [
|
||||
"pending",
|
||||
"running",
|
||||
"done",
|
||||
"failed",
|
||||
]);
|
||||
|
||||
export const users = pgTable("users", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
email: text("email").notNull().unique(),
|
||||
|
|
@ -34,7 +41,7 @@ export const apps = pgTable("apps", {
|
|||
.references(() => users.id),
|
||||
dekEncrypted: text("dek_encrypted").notNull(),
|
||||
maxSessions: integer("max_sessions").notNull().default(10),
|
||||
rotationStatus: text("rotation_status"),
|
||||
rotationStatus: rotationStatusEnum("rotation_status"),
|
||||
rotationJobId: text("rotation_job_id"),
|
||||
rotationStartedAt: timestamp("rotation_started_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 { apps, apiKeys } from "#/db/schema";
|
||||
import { generateDek, wrapDek } from "#/lib/crypto";
|
||||
|
|
@ -105,11 +105,33 @@ export async function startKeyRotation(
|
|||
appId: string,
|
||||
userId: 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);
|
||||
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 };
|
||||
}
|
||||
|
||||
|
|
@ -122,12 +144,7 @@ export async function startKeyRotation(
|
|||
|
||||
await db
|
||||
.update(apps)
|
||||
.set({
|
||||
rotationStatus: "pending",
|
||||
rotationJobId: String(job.id),
|
||||
rotationStartedAt: new Date(),
|
||||
rotationCompletedAt: null,
|
||||
})
|
||||
.set({ rotationJobId: String(job.id) })
|
||||
.where(eq(apps.id, appId));
|
||||
|
||||
await writeAudit({
|
||||
|
|
@ -146,7 +163,11 @@ export async function startKeyRotation(
|
|||
export async function getKeyRotationStatus(
|
||||
appId: 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);
|
||||
if (!app) return null;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
import { and, eq, inArray, sql } from "drizzle-orm";
|
||||
import { db } from "#/db/index";
|
||||
import { apps, contacts, contactTags, contactGroupMembers } from "#/db/schema";
|
||||
import { unwrapDek } from "#/lib/crypto";
|
||||
import { contacts, contactTags, contactGroupMembers } from "#/db/schema";
|
||||
import {
|
||||
encryptContact,
|
||||
decryptContact,
|
||||
phoneBlindIndex,
|
||||
normalizePhone,
|
||||
} from "#/lib/contacts-crypto";
|
||||
import { getAppDek } from "#/lib/dek-helper";
|
||||
import { dispatchEvent } from "#/lib/events/dispatch";
|
||||
import { listCustomFieldSchemas } from "#/lib/actions/custom-field-schemas";
|
||||
|
||||
|
|
@ -38,28 +38,42 @@ async function validateCustomFields(
|
|||
for (const schema of schemas) {
|
||||
const val = customFields[schema.key];
|
||||
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 (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 === "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") {
|
||||
return { ok: false, error: `Custom field '${schema.key}' must be a boolean`, status: 400 };
|
||||
if (
|
||||
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 };
|
||||
}
|
||||
|
||||
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 { getAppDek };
|
||||
|
||||
export async function createContact(
|
||||
appId: string,
|
||||
|
|
@ -74,7 +88,8 @@ export async function createContact(
|
|||
},
|
||||
dek?: Buffer,
|
||||
): 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()) {
|
||||
return { ok: false, error: "phone is required", status: 400 };
|
||||
|
|
@ -121,14 +136,13 @@ export async function createContact(
|
|||
})
|
||||
.returning();
|
||||
|
||||
const isCreated =
|
||||
contact.createdAt.getTime() === contact.updatedAt.getTime();
|
||||
dispatchEvent(appId, isCreated ? "contact.created" : "contact.updated", {
|
||||
const created = contact.createdAt.getTime() === contact.updatedAt.getTime();
|
||||
dispatchEvent(appId, created ? "contact.created" : "contact.updated", {
|
||||
contactId: contact.id,
|
||||
source: contact.source,
|
||||
}).catch(() => {});
|
||||
|
||||
return { ok: true, contact };
|
||||
return { ok: true, contact, created };
|
||||
}
|
||||
|
||||
async function getTagsForContacts(
|
||||
|
|
@ -174,61 +188,7 @@ export async function findContact(
|
|||
};
|
||||
}
|
||||
|
||||
export async function listContacts(
|
||||
appId: string,
|
||||
opts: {
|
||||
q?: string;
|
||||
tag?: string;
|
||||
groupId?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
} = {},
|
||||
): Promise<{ contacts: DecryptedContact[]; total: number }> {
|
||||
const page = Math.max(1, opts.page ?? 1);
|
||||
const limit = Math.min(100, Math.max(1, opts.limit ?? 20));
|
||||
|
||||
// Build base query with optional tag/group joins
|
||||
let rows: Contact[];
|
||||
|
||||
if (opts.tag) {
|
||||
const taggedContactIds = (
|
||||
await db.query.contactTags.findMany({
|
||||
where: and(eq(contactTags.appId, appId), eq(contactTags.tag, opts.tag)),
|
||||
columns: { contactId: true },
|
||||
})
|
||||
).map((r) => r.contactId);
|
||||
|
||||
if (taggedContactIds.length === 0) return { contacts: [], total: 0 };
|
||||
|
||||
rows = await db.query.contacts.findMany({
|
||||
where: and(
|
||||
eq(contacts.appId, appId),
|
||||
inArray(contacts.id, taggedContactIds),
|
||||
),
|
||||
});
|
||||
} else if (opts.groupId) {
|
||||
const memberIds = (
|
||||
await db.query.contactGroupMembers.findMany({
|
||||
where: eq(contactGroupMembers.groupId, opts.groupId),
|
||||
columns: { contactId: true },
|
||||
})
|
||||
).map((r) => r.contactId);
|
||||
|
||||
if (memberIds.length === 0) return { contacts: [], total: 0 };
|
||||
|
||||
rows = await db.query.contacts.findMany({
|
||||
where: and(eq(contacts.appId, appId), inArray(contacts.id, memberIds)),
|
||||
});
|
||||
} else {
|
||||
rows = await db.query.contacts.findMany({
|
||||
where: eq(contacts.appId, appId),
|
||||
});
|
||||
}
|
||||
|
||||
const dek = await getAppDek(appId);
|
||||
|
||||
// Decrypt all
|
||||
let decryptedRows: DecryptedContact[] = rows.map((row) => {
|
||||
function decryptRow(row: Contact, dek: Buffer): DecryptedContact {
|
||||
let decrypted: ReturnType<typeof decryptContact>;
|
||||
try {
|
||||
decrypted = decryptContact(row, dek);
|
||||
|
|
@ -246,48 +206,123 @@ export async function listContacts(
|
|||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// Client-side filter by q
|
||||
export async function listContacts(
|
||||
appId: string,
|
||||
opts: {
|
||||
q?: string;
|
||||
tag?: string;
|
||||
groupId?: string;
|
||||
page?: number;
|
||||
limit?: number;
|
||||
} = {},
|
||||
): Promise<{ contacts: DecryptedContact[]; total: number }> {
|
||||
const page = Math.max(1, opts.page ?? 1);
|
||||
const limit = Math.min(100, Math.max(1, opts.limit ?? 20));
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const dek = await getAppDek(appId);
|
||||
|
||||
// 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 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;
|
||||
const rows = await db.query.contacts.findMany({
|
||||
where: eq(contacts.appId, appId),
|
||||
});
|
||||
} 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 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 total = decryptedRows.length;
|
||||
|
||||
// Paginate
|
||||
const paginated = decryptedRows.slice((page - 1) * limit, page * limit);
|
||||
|
||||
// Load tags for paginated rows
|
||||
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) ?? [];
|
||||
for (const r of paginated) r.tags = tagsMap.get(r.id) ?? [];
|
||||
return { contacts: paginated, total: decryptedRows.length };
|
||||
}
|
||||
|
||||
return { contacts: paginated, total };
|
||||
// Tag filter
|
||||
if (opts.tag) {
|
||||
const taggedIds = (
|
||||
await db.query.contactTags.findMany({
|
||||
where: and(eq(contactTags.appId, appId), eq(contactTags.tag, opts.tag)),
|
||||
columns: { contactId: true },
|
||||
})
|
||||
).map((r) => r.contactId);
|
||||
if (taggedIds.length === 0) return { contacts: [], total: 0 };
|
||||
|
||||
const rows = await db.query.contacts.findMany({
|
||||
where: and(eq(contacts.appId, appId), inArray(contacts.id, taggedIds)),
|
||||
limit,
|
||||
offset,
|
||||
orderBy: (c, { desc }) => [desc(c.createdAt)],
|
||||
});
|
||||
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 = (
|
||||
await db.query.contactGroupMembers.findMany({
|
||||
where: eq(contactGroupMembers.groupId, opts.groupId),
|
||||
columns: { contactId: true },
|
||||
})
|
||||
).map((r) => r.contactId);
|
||||
if (memberIds.length === 0) return { contacts: [], total: 0 };
|
||||
|
||||
const rows = await db.query.contacts.findMany({
|
||||
where: and(eq(contacts.appId, appId), inArray(contacts.id, memberIds)),
|
||||
limit,
|
||||
offset,
|
||||
orderBy: (c, { desc }) => [desc(c.createdAt)],
|
||||
});
|
||||
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: memberIds.length };
|
||||
}
|
||||
|
||||
// No filter: push pagination to DB
|
||||
const [{ count }] = await db
|
||||
.select({ count: sql<number>`count(*)::int` })
|
||||
.from(contacts)
|
||||
.where(eq(contacts.appId, appId));
|
||||
|
||||
const rows = await db.query.contacts.findMany({
|
||||
where: eq(contacts.appId, appId),
|
||||
limit,
|
||||
offset,
|
||||
orderBy: (c, { desc }) => [desc(c.createdAt)],
|
||||
});
|
||||
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: count };
|
||||
}
|
||||
|
||||
export async function updateContact(
|
||||
|
|
@ -370,20 +405,25 @@ export async function deleteContact(
|
|||
return { ok: true };
|
||||
}
|
||||
|
||||
const DEBUG_CONTACTS = process.env.DEBUG?.includes("baileys");
|
||||
|
||||
export async function upsertContactFromWhatsApp(
|
||||
appId: string,
|
||||
jid: string,
|
||||
pushName?: string,
|
||||
dek?: Buffer,
|
||||
): 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];
|
||||
if (!phone || !/^\d+$/.test(phone)) {
|
||||
if (DEBUG_CONTACTS)
|
||||
console.log(`[contacts] upsertFromWA skip non-user jid="${jid}"`);
|
||||
return { ok: false, error: `Non-user JID: ${jid}`, status: 400 };
|
||||
}
|
||||
|
||||
if (DEBUG_CONTACTS)
|
||||
console.log(
|
||||
`[contacts] upsertFromWA jid="${jid}" pushName="${pushName ?? ""}"`,
|
||||
);
|
||||
|
|
@ -398,11 +438,13 @@ export async function upsertContactFromWhatsApp(
|
|||
},
|
||||
dek,
|
||||
);
|
||||
if (DEBUG_CONTACTS) {
|
||||
if (result.ok) {
|
||||
console.log(`[contacts] saved id=${result.contact.id} phone=${phone}`);
|
||||
} else {
|
||||
console.log(`[contacts] save failed: ${result.error}`);
|
||||
}
|
||||
}
|
||||
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 { apps, webhookEndpoints, webhookDeliveries } from "#/db/schema";
|
||||
import { encryptColumn, decryptColumn, unwrapDek } from "#/lib/crypto";
|
||||
import { webhookEndpoints, webhookDeliveries } from "#/db/schema";
|
||||
import { encryptColumn, decryptColumn } from "#/lib/crypto";
|
||||
import { getAppDek } from "#/lib/dek-helper";
|
||||
import { randomBytes } from "node:crypto";
|
||||
|
||||
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);
|
||||
|
||||
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);
|
||||
const BLOCKED_HOSTNAMES =
|
||||
/^(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;
|
||||
|
||||
function validateWebhookUrl(
|
||||
raw: string,
|
||||
): { ok: true } | { ok: false; error: string } {
|
||||
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 {
|
||||
|
|
@ -61,7 +77,13 @@ export async function createWebhookEndpoint(
|
|||
url: string,
|
||||
events: 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 secret = secretInput ?? generateSecret();
|
||||
const secretEncrypted = encryptColumn(secret, dek);
|
||||
|
|
@ -71,7 +93,7 @@ export async function createWebhookEndpoint(
|
|||
.values({ appId, url, events, secretEncrypted })
|
||||
.returning();
|
||||
|
||||
return { endpoint: toPublic(row), secret };
|
||||
return { ok: true, endpoint: toPublic(row), secret };
|
||||
}
|
||||
|
||||
export async function findWebhookEndpoint(
|
||||
|
|
@ -105,7 +127,11 @@ export async function updateWebhookEndpoint(
|
|||
if (!existing) return { ok: false, error: "Endpoint not found", status: 404 };
|
||||
|
||||
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.enabled !== undefined) set.enabled = updates.enabled;
|
||||
if (updates.secret !== undefined) {
|
||||
|
|
@ -194,7 +220,6 @@ export async function updateDeliverySuccess(
|
|||
})
|
||||
.where(eq(webhookDeliveries.id, deliveryId));
|
||||
|
||||
// Reset consecutive failures on success
|
||||
const delivery = await db.query.webhookDeliveries.findFirst({
|
||||
where: eq(webhookDeliveries.id, deliveryId),
|
||||
columns: { endpointId: true },
|
||||
|
|
@ -209,6 +234,7 @@ export async function updateDeliverySuccess(
|
|||
|
||||
export async function updateDeliveryFailed(
|
||||
deliveryId: string,
|
||||
endpointId: string,
|
||||
httpStatus: number | null,
|
||||
attempt: number,
|
||||
nextAttemptAt: Date | null,
|
||||
|
|
@ -223,18 +249,12 @@ export async function updateDeliveryFailed(
|
|||
})
|
||||
.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
|
||||
.update(webhookEndpoints)
|
||||
.set({
|
||||
consecutiveFailures: sql`${webhookEndpoints.consecutiveFailures} + 1`,
|
||||
})
|
||||
.where(eq(webhookEndpoints.id, delivery.endpointId))
|
||||
.where(eq(webhookEndpoints.id, endpointId))
|
||||
.returning({
|
||||
id: webhookEndpoints.id,
|
||||
consecutiveFailures: webhookEndpoints.consecutiveFailures,
|
||||
|
|
@ -274,21 +294,13 @@ export async function pruneDeliveries(endpointId: string): Promise<void> {
|
|||
|
||||
if (keep.length < WEBHOOK_LOG_RETAIN) return;
|
||||
|
||||
const oldestKept = keep[keep.length - 1] as (typeof keep)[number] | undefined;
|
||||
if (!oldestKept) return;
|
||||
|
||||
const cutoffRow = await db.query.webhookDeliveries.findFirst({
|
||||
where: eq(webhookDeliveries.id, oldestKept.id),
|
||||
columns: { createdAt: true },
|
||||
});
|
||||
if (!cutoffRow) return;
|
||||
|
||||
const keepIds = keep.map((r) => r.id);
|
||||
await db
|
||||
.delete(webhookDeliveries)
|
||||
.where(
|
||||
and(
|
||||
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 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
|
||||
declare global {
|
||||
|
|
@ -123,14 +124,15 @@ export async function startSession(
|
|||
// 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
|
||||
sock.ev.on("contacts.upsert", async (contactList) => {
|
||||
if (DEBUG_BAILEYS) {
|
||||
console.log(`[contacts.upsert] >>> FIRED count=${contactList.length}`);
|
||||
if (contactList[0]) {
|
||||
// Log raw shape of first contact so we see all available fields
|
||||
console.log(
|
||||
`[contacts.upsert] sample[0] raw=`,
|
||||
JSON.stringify(contactList[0]),
|
||||
);
|
||||
}
|
||||
}
|
||||
const { upsertContactFromWhatsApp } =
|
||||
await import("#/lib/actions/contacts");
|
||||
for (const contact of contactList) {
|
||||
|
|
@ -146,6 +148,7 @@ export async function startSession(
|
|||
});
|
||||
|
||||
sock.ev.on("contacts.update", (updates) => {
|
||||
if (DEBUG_BAILEYS) {
|
||||
console.log(`[contacts.update] >>> FIRED count=${updates.length}`);
|
||||
if (updates[0]) {
|
||||
console.log(
|
||||
|
|
@ -153,6 +156,7 @@ export async function startSession(
|
|||
JSON.stringify(updates[0]),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
sock.ev.on("creds.update", async (patch) => {
|
||||
|
|
@ -176,6 +180,7 @@ export async function startSession(
|
|||
// Upsert contact on every message — reliable fallback for when contacts.upsert misses
|
||||
if (jid.endsWith("@s.whatsapp.net")) {
|
||||
const pushName = (msg as { pushName?: string }).pushName ?? undefined;
|
||||
if (DEBUG_BAILEYS)
|
||||
console.log(
|
||||
`[contacts] msg-fallback upsert jid="${jid}" pushName="${pushName ?? ""}"`,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -4,6 +4,15 @@ import { apps } from "#/db/schema";
|
|||
import { unwrapDek } from "#/lib/crypto";
|
||||
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(
|
||||
sessionId: 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) {
|
||||
// Determine if created or updated based on timestamps
|
||||
const c = result.contact;
|
||||
const isNew =
|
||||
Math.abs(c.createdAt.getTime() - c.updatedAt.getTime()) < 1000;
|
||||
if (isNew) {
|
||||
if (result.created) {
|
||||
created++;
|
||||
} else {
|
||||
updated++;
|
||||
|
|
|
|||
|
|
@ -2,7 +2,13 @@ import type { Worker } from "bullmq";
|
|||
import { eq } from "drizzle-orm";
|
||||
import { createWorker, QUEUE_KEY_ROTATION } from "./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 { phoneBlindIndex } from "#/lib/contacts-crypto";
|
||||
import { writeAudit } from "#/lib/audit";
|
||||
|
|
@ -54,10 +60,16 @@ export function startRotationWorker(): Worker {
|
|||
};
|
||||
|
||||
if (row.nameEnc) {
|
||||
updates.nameEnc = encryptColumn(decryptColumn(row.nameEnc, oldDek), newDek);
|
||||
updates.nameEnc = encryptColumn(
|
||||
decryptColumn(row.nameEnc, oldDek),
|
||||
newDek,
|
||||
);
|
||||
}
|
||||
if (row.emailEnc) {
|
||||
updates.emailEnc = encryptColumn(decryptColumn(row.emailEnc, oldDek), newDek);
|
||||
updates.emailEnc = encryptColumn(
|
||||
decryptColumn(row.emailEnc, oldDek),
|
||||
newDek,
|
||||
);
|
||||
}
|
||||
if (row.customFieldsEnc) {
|
||||
updates.customFieldsEnc = encryptColumn(
|
||||
|
|
@ -83,6 +95,33 @@ export function startRotationWorker(): Worker {
|
|||
.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
|
||||
.update(apps)
|
||||
.set({
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ export function startWebhookWorker(): Worker {
|
|||
|
||||
const secret = await getEndpointSecret(endpointId, appId);
|
||||
if (!secret) {
|
||||
await updateDeliveryFailed(deliveryId, null, attempt, null);
|
||||
await updateDeliveryFailed(deliveryId, endpointId, null, attempt, null);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -89,12 +89,15 @@ export function startWebhookWorker(): Worker {
|
|||
}
|
||||
|
||||
const nextAttemptIndex = attempt + 1;
|
||||
const nextDelay = (RETRY_DELAYS as (number | undefined)[])[nextAttemptIndex];
|
||||
const nextDelay = (RETRY_DELAYS as (number | undefined)[])[
|
||||
nextAttemptIndex
|
||||
];
|
||||
const nextAttemptAt =
|
||||
nextDelay !== undefined ? new Date(Date.now() + nextDelay) : null;
|
||||
|
||||
await updateDeliveryFailed(
|
||||
deliveryId,
|
||||
endpointId,
|
||||
httpStatus,
|
||||
attempt + 1,
|
||||
nextAttemptAt,
|
||||
|
|
|
|||
|
|
@ -1,24 +1,19 @@
|
|||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { RateLimiterRedis } from "rate-limiter-flexible";
|
||||
import Redis from "ioredis";
|
||||
import { verifyPassword } from "#/lib/password";
|
||||
import { createSessionCookie, json, jsonError } from "#/lib/session-api";
|
||||
import { writeAudit } from "#/lib/audit";
|
||||
import { findUserByEmail, verifyTotpToken } from "#/lib/actions/auth";
|
||||
import { getRedisConnection } from "#/lib/queue";
|
||||
|
||||
const loginAttempts = new Map<string, { count: number; resetAt: number }>();
|
||||
const LOGIN_WINDOW_MS = 15 * 60 * 1000;
|
||||
const LOGIN_LIMIT = 10;
|
||||
|
||||
function checkLoginRateLimit(ip: string): boolean {
|
||||
const now = Date.now();
|
||||
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;
|
||||
}
|
||||
const loginLimiter = new RateLimiterRedis({
|
||||
storeClient: new Redis(getRedisConnection()),
|
||||
keyPrefix: "login_rl",
|
||||
points: 10,
|
||||
duration: 15 * 60,
|
||||
blockDuration: 15 * 60,
|
||||
});
|
||||
|
||||
export const Route = createFileRoute("/api/auth/login")({
|
||||
server: {
|
||||
|
|
@ -30,7 +25,9 @@ export const Route = createFileRoute("/api/auth/login")({
|
|||
request.headers.get("x-real-ip") ??
|
||||
"unknown";
|
||||
|
||||
if (!checkLoginRateLimit(ip)) {
|
||||
try {
|
||||
await loginLimiter.consume(ip);
|
||||
} catch {
|
||||
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) {
|
||||
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);
|
||||
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 (!Array.isArray(body.events))
|
||||
return jsonError("events must be an array", 400);
|
||||
const { endpoint, secret } = await createWebhookEndpoint(
|
||||
const result = await createWebhookEndpoint(
|
||||
body.app_id,
|
||||
body.url,
|
||||
body.events,
|
||||
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,17 +19,27 @@ export const Route = createFileRoute("/api/v1/contacts/import/")({
|
|||
}
|
||||
|
||||
// Parse multipart
|
||||
const MAX_CSV_BYTES = 10 * 1024 * 1024; // 10 MB
|
||||
let csvContent = "";
|
||||
let columnMapRaw = "";
|
||||
let dryRunRaw = "false";
|
||||
let csvTooLarge = false;
|
||||
|
||||
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) => {
|
||||
const chunks: Buffer[] = [];
|
||||
stream.on("data", (chunk: Buffer) => chunks.push(chunk));
|
||||
stream.on("limit", () => {
|
||||
csvTooLarge = true;
|
||||
stream.resume();
|
||||
});
|
||||
stream.on("end", () => {
|
||||
if (!csvTooLarge)
|
||||
csvContent = Buffer.concat(chunks).toString("utf8");
|
||||
});
|
||||
stream.on("error", reject);
|
||||
|
|
@ -52,6 +62,11 @@ export const Route = createFileRoute("/api/v1/contacts/import/")({
|
|||
.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()) {
|
||||
return jsonError("CSV file is required", 400);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,13 @@ export const Route = createFileRoute("/api/v1/sessions/$id/messages/broadcast")(
|
|||
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)) {
|
||||
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))
|
||||
return jsonError("events must be an array", 400);
|
||||
|
||||
const { endpoint, secret } = await createWebhookEndpoint(
|
||||
const result = await createWebhookEndpoint(
|
||||
auth.appId,
|
||||
body.url,
|
||||
body.events,
|
||||
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",
|
||||
)}
|
||||
>
|
||||
{isDone ? (
|
||||
<CheckCircle size={12} strokeWidth={2.5} />
|
||||
) : (
|
||||
i + 1
|
||||
)}
|
||||
{isDone ? <CheckCircle size={12} strokeWidth={2.5} /> : i + 1}
|
||||
</div>
|
||||
<span
|
||||
className={cn(
|
||||
|
|
@ -345,7 +341,6 @@ function ContactImportPage() {
|
|||
|
||||
{/* Content */}
|
||||
<div className="flex-1 px-8 py-8">
|
||||
|
||||
{/* ── UPLOAD ── */}
|
||||
{step === "upload" && (
|
||||
<div className="grid grid-cols-[320px_1fr] gap-10 items-start max-w-5xl">
|
||||
|
|
@ -368,7 +363,11 @@ function ContactImportPage() {
|
|||
].map((req) => (
|
||||
<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">
|
||||
<CheckCircle size={9} className="text-white" strokeWidth={3} />
|
||||
<CheckCircle
|
||||
size={9}
|
||||
className="text-white"
|
||||
strokeWidth={3}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[13px] text-gray-600">{req}</p>
|
||||
</div>
|
||||
|
|
@ -380,7 +379,7 @@ function ContactImportPage() {
|
|||
Example format
|
||||
</p>
|
||||
<pre className="text-[11px] text-gray-600 font-mono leading-relaxed">
|
||||
{`phone,name,email
|
||||
{`phone,name,email
|
||||
+6281234567890,John Doe,john@co.com
|
||||
+6289876543210,Jane Smith,`}
|
||||
</pre>
|
||||
|
|
@ -406,14 +405,19 @@ function ContactImportPage() {
|
|||
{file ? (
|
||||
<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">
|
||||
<FileText size={20} className="text-white" strokeWidth={1.5} />
|
||||
<FileText
|
||||
size={20}
|
||||
className="text-white"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-[14px] font-semibold text-gray-900 truncate">
|
||||
{file.name}
|
||||
</p>
|
||||
<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>
|
||||
</div>
|
||||
<button
|
||||
|
|
@ -519,10 +523,26 @@ function ContactImportPage() {
|
|||
setter: setEmailCol,
|
||||
required: false,
|
||||
},
|
||||
].map(({ key, label, hint, icon: Icon, value, setter, required }) => (
|
||||
<div key={key} className="flex items-center gap-4 px-4 py-3.5">
|
||||
].map(
|
||||
({
|
||||
key,
|
||||
label,
|
||||
hint,
|
||||
icon: Icon,
|
||||
value,
|
||||
setter,
|
||||
required,
|
||||
}) => (
|
||||
<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">
|
||||
<Icon size={14} className="text-gray-500" strokeWidth={1.75} />
|
||||
<Icon
|
||||
size={14}
|
||||
className="text-gray-500"
|
||||
strokeWidth={1.75}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
|
|
@ -545,14 +565,19 @@ function ContactImportPage() {
|
|||
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>
|
||||
<option value="">
|
||||
{required ? "Select column…" : "None"}
|
||||
</option>
|
||||
{headers.map((h) => (
|
||||
<option key={h} value={h}>{h}</option>
|
||||
<option key={h} value={h}>
|
||||
{h}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
|
||||
{previewError && (
|
||||
|
|
@ -592,7 +617,11 @@ function ContactImportPage() {
|
|||
<div className="space-y-4">
|
||||
<div>
|
||||
<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
|
||||
<span className="text-[11px] font-medium text-gray-400 bg-gray-100 rounded-full px-2 py-0.5">
|
||||
{headers.length}
|
||||
|
|
@ -681,7 +710,10 @@ function ContactImportPage() {
|
|||
<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">
|
||||
{["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">
|
||||
{col}
|
||||
</span>
|
||||
|
|
@ -754,7 +786,11 @@ function ContactImportPage() {
|
|||
{step === "importing" && (
|
||||
<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">
|
||||
<Loader size={24} className="text-white animate-spin" strokeWidth={1.5} />
|
||||
<Loader
|
||||
size={24}
|
||||
className="text-white animate-spin"
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<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="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">
|
||||
<CheckCircle size={15} className="text-emerald-600" strokeWidth={2} />
|
||||
<CheckCircle
|
||||
size={15}
|
||||
className="text-emerald-600"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[28px] font-bold text-gray-900 tabular-nums leading-none mb-1">
|
||||
{importResult.created}
|
||||
|
|
@ -800,14 +840,16 @@ function ContactImportPage() {
|
|||
|
||||
<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">
|
||||
<CheckCircle size={15} className="text-blue-500" strokeWidth={2} />
|
||||
<CheckCircle
|
||||
size={15}
|
||||
className="text-blue-500"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[28px] font-bold text-gray-900 tabular-nums leading-none mb-1">
|
||||
{importResult.updated}
|
||||
</p>
|
||||
<p className="text-[12px] text-blue-600 font-medium">
|
||||
Updated
|
||||
</p>
|
||||
<p className="text-[12px] text-blue-600 font-medium">Updated</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
|
|
@ -856,7 +898,8 @@ function ContactImportPage() {
|
|||
{importResult.errors.length > 0 && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-xl p-4">
|
||||
<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>
|
||||
<div className="space-y-1.5 max-h-40 overflow-y-auto">
|
||||
{importResult.errors.map((e, i) => (
|
||||
|
|
|
|||
|
|
@ -568,7 +568,11 @@ function ContactsPage() {
|
|||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<FieldsDialog appId={appId} open={fieldsOpen} onOpenChange={setFieldsOpen} />
|
||||
<FieldsDialog
|
||||
appId={appId}
|
||||
open={fieldsOpen}
|
||||
onOpenChange={setFieldsOpen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,9 +15,7 @@ import {
|
|||
DialogDescription,
|
||||
} from "#/components/ui/dialog";
|
||||
|
||||
export const Route = createFileRoute(
|
||||
"/apps/$appId/contacts/settings/",
|
||||
)({
|
||||
export const Route = createFileRoute("/apps/$appId/contacts/settings/")({
|
||||
component: ContactSettingsPage,
|
||||
});
|
||||
|
||||
|
|
@ -175,7 +173,9 @@ function ContactSettingsPage() {
|
|||
<p className="text-sm text-muted-foreground">Loading…</p>
|
||||
)}
|
||||
{!(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">
|
||||
|
|
@ -199,11 +199,7 @@ function ContactSettingsPage() {
|
|||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => openEdit(s)}
|
||||
>
|
||||
<Button variant="ghost" size="sm" onClick={() => openEdit(s)}>
|
||||
Edit
|
||||
</Button>
|
||||
<Button
|
||||
|
|
@ -289,7 +285,12 @@ function ContactSettingsPage() {
|
|||
</Dialog>
|
||||
|
||||
{/* Edit dialog */}
|
||||
<Dialog open={!!editId} onOpenChange={(o) => { if (!o) setEditId(null); }}>
|
||||
<Dialog
|
||||
open={!!editId}
|
||||
onOpenChange={(o) => {
|
||||
if (!o) setEditId(null);
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit field</DialogTitle>
|
||||
|
|
@ -316,10 +317,7 @@ function ContactSettingsPage() {
|
|||
<span className="text-sm">Required field</span>
|
||||
</label>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setEditId(null)}
|
||||
>
|
||||
<Button variant="outline" onClick={() => setEditId(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={saveEdit} disabled={saving}>
|
||||
|
|
@ -333,7 +331,9 @@ function ContactSettingsPage() {
|
|||
{/* Delete confirm */}
|
||||
<Dialog
|
||||
open={!!deleteId}
|
||||
onOpenChange={(o) => { if (!o) setDeleteId(null); }}
|
||||
onOpenChange={(o) => {
|
||||
if (!o) setDeleteId(null);
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,16 @@
|
|||
import { createFileRoute, useRouter } from "@tanstack/react-router";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
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 { Button } from "#/components/ui/button";
|
||||
import { Input } from "#/components/ui/input";
|
||||
|
|
@ -319,21 +328,29 @@ function AppKeysPage() {
|
|||
Rotate encryption key
|
||||
</p>
|
||||
<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>
|
||||
{rotationStatus && (
|
||||
<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">
|
||||
<RefreshCw size={11} className="animate-spin" />
|
||||
Rotation in progress…
|
||||
</div>
|
||||
) : rotationStatus.status === "done" ? (
|
||||
<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>
|
||||
) : 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}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -463,9 +480,14 @@ function AppKeysPage() {
|
|||
>
|
||||
<DialogContent className="sm:max-w-[400px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-[15px]">Rotate encryption key?</DialogTitle>
|
||||
<DialogTitle className="text-[15px]">
|
||||
Rotate encryption key?
|
||||
</DialogTitle>
|
||||
<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>
|
||||
</DialogHeader>
|
||||
{rotateError && (
|
||||
|
|
|
|||
|
|
@ -309,9 +309,11 @@ function WebhooksPage() {
|
|||
onChange={(e) => setNewUrl(e.target.value)}
|
||||
required
|
||||
/>
|
||||
{newUrl.startsWith("http://") && !newUrl.startsWith("https://") && (
|
||||
{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.
|
||||
Warning: HTTP endpoints receive webhook payloads
|
||||
unencrypted. Use HTTPS in production.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -617,7 +617,10 @@ describe("custom field schema validation — AC6", () => {
|
|||
|
||||
// Permissive coercion — production allows these
|
||||
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);
|
||||
});
|
||||
|
||||
|
|
@ -631,12 +634,18 @@ describe("custom field schema validation — AC6", () => {
|
|||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -35,7 +35,8 @@ function verifySignature(
|
|||
}
|
||||
|
||||
describe("HMAC webhook signing (FR-W3)", () => {
|
||||
const SECRET = "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890";
|
||||
const SECRET =
|
||||
"abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890";
|
||||
const BODY = JSON.stringify({ event: "message.received", data: { id: "x" } });
|
||||
const TS = "1747382400000";
|
||||
|
||||
|
|
@ -53,11 +54,14 @@ describe("HMAC webhook signing (FR-W3)", () => {
|
|||
});
|
||||
|
||||
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", () => {
|
||||
const other = "0000000000000000000000000000000000000000000000000000000000000000";
|
||||
const other =
|
||||
"0000000000000000000000000000000000000000000000000000000000000000";
|
||||
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) ────────────────────────────────────────────
|
||||
|
||||
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 {
|
||||
return (RETRY_DELAYS as (number | undefined)[])[attempt];
|
||||
|
|
@ -175,7 +181,12 @@ function applySuccess(endpoint: FakeEndpoint): void {
|
|||
|
||||
describe("consecutive-failure unhealthy threshold", () => {
|
||||
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", () => {
|
||||
|
|
@ -246,7 +257,10 @@ describe("delivery status after attempt", () => {
|
|||
|
||||
// ─── 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
|
||||
return endpointEvents.includes(eventType) || endpointEvents.includes("*");
|
||||
}
|
||||
|
|
@ -258,11 +272,15 @@ describe("endpoint event-filter matching", () => {
|
|||
});
|
||||
|
||||
it("exact match", () => {
|
||||
expect(endpointMatchesEvent(["message.received"], "message.received")).toBe(true);
|
||||
expect(endpointMatchesEvent(["message.received"], "message.received")).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("no match", () => {
|
||||
expect(endpointMatchesEvent(["message.received"], "message.sent")).toBe(false);
|
||||
expect(endpointMatchesEvent(["message.received"], "message.sent")).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("wildcard * matches any event", () => {
|
||||
|
|
@ -270,19 +288,35 @@ describe("endpoint event-filter matching", () => {
|
|||
});
|
||||
|
||||
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", () => {
|
||||
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", () => {
|
||||
const allEvents = [
|
||||
"session.status", "session.deleted",
|
||||
"message.received", "message.sent", "message.delivered", "message.read", "message.failed",
|
||||
"session.status",
|
||||
"session.deleted",
|
||||
"message.received",
|
||||
"message.sent",
|
||||
"message.delivered",
|
||||
"message.read",
|
||||
"message.failed",
|
||||
"presence.update",
|
||||
"contact.created", "contact.updated",
|
||||
"contact.created",
|
||||
"contact.updated",
|
||||
];
|
||||
for (const evt of allEvents) {
|
||||
expect(endpointMatchesEvent(["*"], evt)).toBe(true);
|
||||
|
|
@ -316,7 +350,12 @@ function enrichPayload(
|
|||
|
||||
describe("payload enrichment", () => {
|
||||
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_id).toBe("evt-123");
|
||||
expect(p.app_id).toBe("app-abc");
|
||||
|
|
@ -423,14 +462,22 @@ describe("delivery log retention (pruneDeliveries)", () => {
|
|||
expect(forEp).toHaveLength(10);
|
||||
// Newest 10 kept (indices 5-14)
|
||||
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", () => {
|
||||
const d = [
|
||||
...makeDeliveries(15, "ep1"),
|
||||
...makeDeliveries(5, "ep2"),
|
||||
];
|
||||
const d = [...makeDeliveries(15, "ep1"), ...makeDeliveries(5, "ep2")];
|
||||
const result = pruneDeliveries(d, "ep1", RETAIN);
|
||||
const ep2 = result.filter((x) => x.endpointId === "ep2");
|
||||
expect(ep2).toHaveLength(5);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue