refactor: code review improvements

This commit is contained in:
Edqe14 2026-05-16 15:46:12 +07:00
parent b40333a2cd
commit 79520fe28f
Signed by: yukabi
GPG key ID: A1AAC4DC1157D70D
32 changed files with 3146 additions and 799 deletions

View file

@ -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 |

View file

@ -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 |

View 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";

View file

@ -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": {},

File diff suppressed because it is too large Load diff

View file

@ -50,6 +50,13 @@
"when": 1778918643395,
"tag": "0006_tricky_korg",
"breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1778920492721,
"tag": "0007_silky_sentinel",
"breakpoints": true
}
]
}

View file

@ -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/**",
],
},
];

View file

@ -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",

View file

@ -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} />

View file

@ -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"),

View file

@ -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;

View file

@ -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;
}

View file

@ -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),
),
);
}

View file

@ -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 ?? ""}"`,
);

View file

@ -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

View file

@ -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++;

View file

@ -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({

View file

@ -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,

View file

@ -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);
}

View file

@ -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);
},

View file

@ -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);
},
},
},

View file

@ -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);
}

View file

@ -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);
}

View file

@ -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);
},
},
},

View file

@ -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) => (

View file

@ -568,7 +568,11 @@ function ContactsPage() {
</DialogContent>
</Dialog>
<FieldsDialog appId={appId} open={fieldsOpen} onOpenChange={setFieldsOpen} />
<FieldsDialog
appId={appId}
open={fieldsOpen}
onOpenChange={setFieldsOpen}
/>
</div>
);
}

View file

@ -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>

View file

@ -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 && (

View file

@ -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>

View file

@ -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);
});

View file

@ -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);