feat: webhook groups

This commit is contained in:
Edqe14 2026-05-16 17:27:23 +07:00
parent f4632e77d5
commit 92145d36b1
Signed by: yukabi
GPG key ID: A1AAC4DC1157D70D
20 changed files with 3460 additions and 15 deletions

View file

@ -71,6 +71,36 @@ Default: 60 req/s burst, 10 req/s sustained per API key. Configurable. Returns `
---
## WhatsApp Groups
Groups are tracked automatically when the session joins a group or receives messages from one. Records sync on connect and update in real-time via Baileys events.
| Method | Path | Description |
| ------ | -------------------------------------- | ---------------------------------------- |
| GET | `/v1/whatsapp-groups` | List tracked groups. `session_id` required. |
| GET | `/v1/whatsapp-groups/:jid` | Single group by JID. `session_id` required. |
`:jid` must be URL-encoded (e.g. `120363429229698693%40g.us`).
**Group object:**
```json
{
"id": "uuid",
"appId": "uuid",
"sessionId": "uuid",
"jid": "120363429229698693@g.us",
"subject": "Dev Team",
"description": "Internal dev chat",
"participantCount": 12,
"createdAt": "2026-05-16T10:00:00Z",
"updatedAt": "2026-05-16T10:00:00Z"
}
```
`subject` and `description` may be `null` if metadata has not been received yet. `participantCount` is updated incrementally on `group.participants_added` / `group.participants_removed` and replaced wholesale on `group.joined` / history sync.
---
## Webhooks
| Method | Path | Description |

View file

@ -19,18 +19,71 @@ If `secret` is omitted, one is generated. **Store it — shown once.**
## Event types
| Event | When fired |
| ------------------- | ------------------------------------- |
| `session.status` | Any session state transition |
| `session.deleted` | Session deleted via API |
| `message.received` | Inbound message |
| `message.sent` | Outbound message accepted by WhatsApp |
| `message.delivered` | WhatsApp delivery receipt received |
| `message.read` | Read receipt received |
| `message.failed` | Send failed |
| `presence.update` | Contact presence change |
| `contact.created` | New contact added |
| `contact.updated` | Contact modified |
| Event | When fired |
| --------------------------- | ----------------------------------------------- |
| `session.status` | Any session state transition |
| `session.deleted` | Session deleted via API |
| `message.received` | Inbound message |
| `message.sent` | Outbound message accepted by WhatsApp |
| `message.delivered` | WhatsApp delivery receipt received |
| `message.read` | Read receipt received |
| `message.failed` | Send failed |
| `presence.update` | Contact presence change |
| `contact.created` | New contact added |
| `contact.updated` | Contact modified |
| `group.joined` | Session was added to a WhatsApp group |
| `group.left` | Session was removed or kicked from a group |
| `group.updated` | Group subject or description changed |
| `group.participants_added` | Other participants added to a group |
| `group.participants_removed`| Other participants removed from a group |
---
## Group event payloads
All group payloads appear in the `data` field of the standard envelope (see [Payload structure](#payload-structure) below).
**`group.joined`**
```json
{
"sessionId": "uuid",
"jid": "120363429229698693@g.us",
"subject": "Dev Team",
"description": "Internal dev chat",
"participantCount": 12
}
```
**`group.left`**
```json
{
"sessionId": "uuid",
"jid": "120363429229698693@g.us",
"participants": ["6281234567890@s.whatsapp.net"]
}
```
`participants` contains the JID(s) that triggered the removal (may include the session's own JID when kicked).
**`group.updated`**
```json
{
"sessionId": "uuid",
"jid": "120363429229698693@g.us",
"subject": "New Name",
"description": null
}
```
Only fields reported by WhatsApp are present. `null` means the field was explicitly cleared.
**`group.participants_added`** / **`group.participants_removed`**
```json
{
"sessionId": "uuid",
"jid": "120363429229698693@g.us",
"participants": ["491234567890@s.whatsapp.net", "441234567890@s.whatsapp.net"]
}
```
These fire for other participants — not for the session account itself (that triggers `group.joined` / `group.left`).
---

View file

@ -0,0 +1,17 @@
CREATE TABLE "whatsapp_groups" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"app_id" uuid NOT NULL,
"session_id" uuid NOT NULL,
"jid" text NOT NULL,
"subject" text,
"description" text,
"participant_count" integer,
"created_at" timestamp DEFAULT now() NOT NULL,
"updated_at" timestamp DEFAULT now() NOT NULL,
CONSTRAINT "whatsapp_groups_session_jid_unique" UNIQUE("session_id","jid")
);
--> statement-breakpoint
ALTER TABLE "whatsapp_groups" ADD CONSTRAINT "whatsapp_groups_app_id_apps_id_fk" FOREIGN KEY ("app_id") REFERENCES "public"."apps"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "whatsapp_groups" ADD CONSTRAINT "whatsapp_groups_session_id_sessions_id_fk" FOREIGN KEY ("session_id") REFERENCES "public"."sessions"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "whatsapp_groups_app_id_idx" ON "whatsapp_groups" USING btree ("app_id");--> statement-breakpoint
CREATE INDEX "whatsapp_groups_session_id_idx" ON "whatsapp_groups" USING btree ("session_id");

File diff suppressed because it is too large Load diff

View file

@ -57,6 +57,13 @@
"when": 1778920492721,
"tag": "0007_silky_sentinel",
"breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1778923691302,
"tag": "0008_tricky_miek",
"breakpoints": true
}
]
}

View file

@ -9,6 +9,7 @@ import {
ChevronLeft,
Key,
Users,
Users2,
Webhook,
Activity,
} from "lucide-react";
@ -132,14 +133,17 @@ function AppContextNav({
const sessionsPath = `/apps/${appId}/sessions`;
const contactsPath = `/apps/${appId}/contacts`;
const webhooksPath = `/apps/${appId}/webhooks`;
const groupsPath = `/apps/${appId}/groups`;
const isContacts = pathname.startsWith(contactsPath);
const isSessions = pathname.startsWith(sessionsPath);
const isWebhooks = pathname.startsWith(webhooksPath);
const isGroups = pathname.startsWith(groupsPath);
const isKeys =
!isContacts &&
!isSessions &&
!isWebhooks &&
!isGroups &&
(pathname === `/apps/${appId}` ||
pathname === `/apps/${appId}/` ||
pathname.startsWith(`/apps/${appId}/`));
@ -245,6 +249,24 @@ function AppContextNav({
Webhooks
</Link>
<Link
to="/apps/$appId/groups/"
params={{ appId }}
className={cn(
"flex items-center gap-2.5 px-3 py-2 rounded-md text-[13px] font-medium transition-colors duration-150 ml-1",
isGroups
? "bg-zinc-800 text-white"
: "text-zinc-400 hover:text-white hover:bg-zinc-900",
)}
>
<Users2
size={13}
strokeWidth={isGroups ? 2 : 1.75}
className={isGroups ? "text-emerald-400" : ""}
/>
Groups
</Link>
{/* Separator */}
<div className="border-t border-zinc-900 my-2 mx-1" />

View file

@ -354,6 +354,40 @@ export const customFieldSchemasRelations = relations(
}),
);
// ─── WhatsApp Groups ─────────────────────────────────────────────────────────
export const whatsappGroups = pgTable(
"whatsapp_groups",
{
id: uuid("id").primaryKey().defaultRandom(),
appId: uuid("app_id")
.notNull()
.references(() => apps.id),
sessionId: uuid("session_id")
.notNull()
.references(() => sessions.id, { onDelete: "cascade" }),
jid: text("jid").notNull(),
subject: text("subject"),
description: text("description"),
participantCount: integer("participant_count"),
createdAt: timestamp("created_at").defaultNow().notNull(),
updatedAt: timestamp("updated_at").defaultNow().notNull(),
},
(t) => [
unique("whatsapp_groups_session_jid_unique").on(t.sessionId, t.jid),
index("whatsapp_groups_app_id_idx").on(t.appId),
index("whatsapp_groups_session_id_idx").on(t.sessionId),
],
);
export const whatsappGroupsRelations = relations(whatsappGroups, ({ one }) => ({
app: one(apps, { fields: [whatsappGroups.appId], references: [apps.id] }),
session: one(sessions, {
fields: [whatsappGroups.sessionId],
references: [sessions.id],
}),
}));
// ─── Webhooks ────────────────────────────────────────────────────────────────
export const webhookDeliveryStatusEnum = pgEnum("webhook_delivery_status", [

View file

@ -0,0 +1,90 @@
import { and, eq, sql } from "drizzle-orm";
import { db } from "#/db/index";
import { whatsappGroups } from "#/db/schema";
type WhatsappGroup = typeof whatsappGroups.$inferSelect;
export async function upsertWhatsappGroup(data: {
appId: string;
sessionId: string;
jid: string;
subject?: string | null;
description?: string | null;
participantCount?: number | null;
}): Promise<WhatsappGroup> {
const set: Partial<typeof whatsappGroups.$inferInsert> = {
updatedAt: new Date(),
};
if (data.subject !== undefined) set.subject = data.subject;
if (data.description !== undefined) set.description = data.description;
if (data.participantCount !== undefined) set.participantCount = data.participantCount;
const [row] = await db
.insert(whatsappGroups)
.values({
appId: data.appId,
sessionId: data.sessionId,
jid: data.jid,
subject: data.subject ?? null,
description: data.description ?? null,
participantCount: data.participantCount ?? null,
})
.onConflictDoUpdate({
target: [whatsappGroups.sessionId, whatsappGroups.jid],
set,
})
.returning();
return row;
}
export async function adjustParticipantCount(
sessionId: string,
jid: string,
delta: number,
): Promise<void> {
await db
.update(whatsappGroups)
.set({
participantCount: sql`GREATEST(0, COALESCE(${whatsappGroups.participantCount}, 0) + ${delta})`,
updatedAt: new Date(),
})
.where(
and(
eq(whatsappGroups.sessionId, sessionId),
eq(whatsappGroups.jid, jid),
),
);
}
export async function listWhatsappGroups(
appId: string,
sessionId?: string,
): Promise<WhatsappGroup[]> {
const conditions = [eq(whatsappGroups.appId, appId)];
if (sessionId) conditions.push(eq(whatsappGroups.sessionId, sessionId));
return db
.select()
.from(whatsappGroups)
.where(and(...conditions))
.orderBy(whatsappGroups.updatedAt);
}
export async function getWhatsappGroup(
jid: string,
sessionId: string,
appId: string,
): Promise<WhatsappGroup | null> {
const [row] = await db
.select()
.from(whatsappGroups)
.where(
and(
eq(whatsappGroups.jid, jid),
eq(whatsappGroups.sessionId, sessionId),
eq(whatsappGroups.appId, appId),
),
)
.limit(1);
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
return row ?? null;
}

View file

@ -12,6 +12,10 @@ import { db } from "#/db/index";
import { apps, sessions } from "#/db/schema";
import { decryptColumn, encryptColumn, unwrapDek } from "#/lib/crypto";
import { createMessage, updateMessageStatus } from "#/lib/actions/messages";
import {
upsertWhatsappGroup,
adjustParticipantCount,
} from "#/lib/actions/whatsapp-groups";
import { getStorageDriver } from "#/lib/storage";
import { usePostgresAuthState } from "./auth-state";
import { emitWebhook } from "./webhook";
@ -333,6 +337,107 @@ export async function startSession(
}
});
sock.ev.on("groups.upsert", async (groups) => {
for (const group of groups) {
await upsertWhatsappGroup({
appId,
sessionId,
jid: group.id,
subject: group.subject,
description: group.desc,
participantCount: group.participants.length,
}).catch(console.error);
emitWebhook(appId, "group.joined", {
sessionId,
jid: group.id,
subject: group.subject,
description: group.desc ?? null,
participantCount: group.participants.length,
});
}
});
sock.ev.on("groups.update", async (updates) => {
for (const update of updates) {
if (!update.id) continue;
await upsertWhatsappGroup({
appId,
sessionId,
jid: update.id,
subject: update.subject,
description: update.desc,
}).catch(console.error);
emitWebhook(appId, "group.updated", {
sessionId,
jid: update.id,
subject: update.subject ?? null,
description: update.desc ?? null,
});
}
});
sock.ev.on("group-participants.update", async ({ id, participants, action }) => {
const ownJid = state.creds.me?.id;
const jidUser = (j: string) => j.split(":")[0].split("@")[0];
const ownRemoved = action === "remove" && ownJid &&
participants.some((p) => p === ownJid || jidUser(p) === jidUser(ownJid));
const ownAdded = action === "add" && ownJid &&
participants.some((p) => p === ownJid || jidUser(p) === jidUser(ownJid));
if (ownAdded) {
try {
const meta = await sock.groupMetadata(id);
await upsertWhatsappGroup({
appId,
sessionId,
jid: id,
subject: meta.subject,
description: meta.desc,
participantCount: meta.participants.length,
});
emitWebhook(appId, "group.joined", {
sessionId,
jid: id,
subject: meta.subject,
description: meta.desc,
participantCount: meta.participants.length,
});
} catch {
await adjustParticipantCount(sessionId, id, participants.length).catch(console.error);
emitWebhook(appId, "group.joined", { sessionId, jid: id });
}
} else if (ownRemoved) {
await adjustParticipantCount(sessionId, id, -participants.length).catch(console.error);
emitWebhook(appId, "group.left", { sessionId, jid: id, participants });
} else if (action === "add") {
await adjustParticipantCount(sessionId, id, participants.length).catch(console.error);
emitWebhook(appId, "group.participants_added", { sessionId, jid: id, participants });
} else if (action === "remove") {
await adjustParticipantCount(sessionId, id, -participants.length).catch(console.error);
emitWebhook(appId, "group.participants_removed", { sessionId, jid: id, participants });
}
});
// History sync on reconnect — captures all current groups
sock.ev.on("messaging-history.set", async ({ chats }) => {
const groupChats = chats.filter((c) => c.id?.endsWith("@g.us"));
for (const chat of groupChats) {
try {
const meta = await sock.groupMetadata(chat.id);
await upsertWhatsappGroup({
appId,
sessionId,
jid: chat.id,
subject: meta.subject,
description: meta.desc,
participantCount: meta.participants.length,
});
} catch {
await upsertWhatsappGroup({ appId, sessionId, jid: chat.id }).catch(console.error);
}
}
});
sock.ev.on("connection.update", async (update) => {
const { connection, lastDisconnect, qr } = update;

View file

@ -149,6 +149,17 @@ export const webhookDeliveriesQuery = (appId: string, webhookId: string) =>
staleTime: 10_000,
});
export const whatsappGroupsQuery = (appId: string, sessionId?: string) => {
const params = new URLSearchParams({ app_id: appId });
if (sessionId) params.set("session_id", sessionId);
return queryOptions({
queryKey: ["whatsapp-groups", appId, sessionId],
queryFn: () =>
apiFetch(`/api/v1/admin/whatsapp-groups?${params.toString()}`),
staleTime: 15_000,
});
};
export const monitoringStatsQuery = queryOptions({
queryKey: ["monitoring", "stats"],
queryFn: () => apiFetch("/api/v1/admin/monitoring/stats"),

View file

@ -28,7 +28,9 @@ import { Route as ApiAuthLogoutRouteImport } from './routes/api/auth/logout'
import { Route as ApiAuthLoginRouteImport } from './routes/api/auth/login'
import { Route as AppsAppIdWebhooksIndexRouteImport } from './routes/apps/$appId/webhooks/index'
import { Route as AppsAppIdSessionsIndexRouteImport } from './routes/apps/$appId/sessions/index'
import { Route as AppsAppIdGroupsIndexRouteImport } from './routes/apps/$appId/groups/index'
import { Route as AppsAppIdContactsIndexRouteImport } from './routes/apps/$appId/contacts/index'
import { Route as ApiV1WhatsappGroupsIndexRouteImport } from './routes/api/v1/whatsapp-groups/index'
import { Route as ApiV1WebhooksIndexRouteImport } from './routes/api/v1/webhooks/index'
import { Route as ApiV1SessionsIndexRouteImport } from './routes/api/v1/sessions/index'
import { Route as ApiV1MediaIndexRouteImport } from './routes/api/v1/media/index'
@ -44,12 +46,14 @@ import { Route as ApiV1AdminAuditLogRouteImport } from './routes/api/v1/admin/au
import { Route as ApiAuthTotpEnrollRouteImport } from './routes/api/auth/totp/enroll'
import { Route as AppsAppIdContactsSettingsIndexRouteImport } from './routes/apps/$appId/contacts/settings/index'
import { Route as AppsAppIdContactsGroupsIndexRouteImport } from './routes/apps/$appId/contacts/groups/index'
import { Route as ApiV1WhatsappGroupsJidIndexRouteImport } from './routes/api/v1/whatsapp-groups/$jid/index'
import { Route as ApiV1WebhooksIdIndexRouteImport } from './routes/api/v1/webhooks/$id/index'
import { Route as ApiV1SessionsIdIndexRouteImport } from './routes/api/v1/sessions/$id/index'
import { Route as ApiV1ContactsSyncFromWhatsappIndexRouteImport } from './routes/api/v1/contacts/sync-from-whatsapp/index'
import { Route as ApiV1ContactsImportIndexRouteImport } from './routes/api/v1/contacts/import/index'
import { Route as ApiV1ContactsIdIndexRouteImport } from './routes/api/v1/contacts/$id/index'
import { Route as ApiV1ContactGroupsIdIndexRouteImport } from './routes/api/v1/contact-groups/$id/index'
import { Route as ApiV1AdminWhatsappGroupsIndexRouteImport } from './routes/api/v1/admin/whatsapp-groups/index'
import { Route as ApiV1AdminWebhooksIndexRouteImport } from './routes/api/v1/admin/webhooks/index'
import { Route as ApiV1AdminSessionsIndexRouteImport } from './routes/api/v1/admin/sessions/index'
import { Route as ApiV1AdminCustomFieldSchemasIndexRouteImport } from './routes/api/v1/admin/custom-field-schemas/index'
@ -69,6 +73,7 @@ import { Route as ApiV1AdminMonitoringStatsRouteImport } from './routes/api/v1/a
import { Route as ApiV1AdminCustomFieldSchemasIdRouteImport } from './routes/api/v1/admin/custom-field-schemas/$id'
import { Route as ApiV1SessionsIdMessagesIndexRouteImport } from './routes/api/v1/sessions/$id/messages/index'
import { Route as ApiV1ContactGroupsIdMembersIndexRouteImport } from './routes/api/v1/contact-groups/$id/members/index'
import { Route as ApiV1AdminWhatsappGroupsJidIndexRouteImport } from './routes/api/v1/admin/whatsapp-groups/$jid/index'
import { Route as ApiV1AdminWebhooksIdIndexRouteImport } from './routes/api/v1/admin/webhooks/$id/index'
import { Route as ApiV1AdminSessionsIdIndexRouteImport } from './routes/api/v1/admin/sessions/$id/index'
import { Route as ApiV1AdminContactsIdIndexRouteImport } from './routes/api/v1/admin/contacts/$id/index'
@ -190,11 +195,22 @@ const AppsAppIdSessionsIndexRoute = AppsAppIdSessionsIndexRouteImport.update({
path: '/sessions/',
getParentRoute: () => AppsAppIdRoute,
} as any)
const AppsAppIdGroupsIndexRoute = AppsAppIdGroupsIndexRouteImport.update({
id: '/groups/',
path: '/groups/',
getParentRoute: () => AppsAppIdRoute,
} as any)
const AppsAppIdContactsIndexRoute = AppsAppIdContactsIndexRouteImport.update({
id: '/contacts/',
path: '/contacts/',
getParentRoute: () => AppsAppIdRoute,
} as any)
const ApiV1WhatsappGroupsIndexRoute =
ApiV1WhatsappGroupsIndexRouteImport.update({
id: '/api/v1/whatsapp-groups/',
path: '/api/v1/whatsapp-groups/',
getParentRoute: () => rootRouteImport,
} as any)
const ApiV1WebhooksIndexRoute = ApiV1WebhooksIndexRouteImport.update({
id: '/api/v1/webhooks/',
path: '/api/v1/webhooks/',
@ -276,6 +292,12 @@ const AppsAppIdContactsGroupsIndexRoute =
path: '/contacts/groups/',
getParentRoute: () => AppsAppIdRoute,
} as any)
const ApiV1WhatsappGroupsJidIndexRoute =
ApiV1WhatsappGroupsJidIndexRouteImport.update({
id: '/api/v1/whatsapp-groups/$jid/',
path: '/api/v1/whatsapp-groups/$jid/',
getParentRoute: () => rootRouteImport,
} as any)
const ApiV1WebhooksIdIndexRoute = ApiV1WebhooksIdIndexRouteImport.update({
id: '/api/v1/webhooks/$id/',
path: '/api/v1/webhooks/$id/',
@ -309,6 +331,12 @@ const ApiV1ContactGroupsIdIndexRoute =
path: '/api/v1/contact-groups/$id/',
getParentRoute: () => rootRouteImport,
} as any)
const ApiV1AdminWhatsappGroupsIndexRoute =
ApiV1AdminWhatsappGroupsIndexRouteImport.update({
id: '/api/v1/admin/whatsapp-groups/',
path: '/api/v1/admin/whatsapp-groups/',
getParentRoute: () => rootRouteImport,
} as any)
const ApiV1AdminWebhooksIndexRoute = ApiV1AdminWebhooksIndexRouteImport.update({
id: '/api/v1/admin/webhooks/',
path: '/api/v1/admin/webhooks/',
@ -414,6 +442,12 @@ const ApiV1ContactGroupsIdMembersIndexRoute =
path: '/api/v1/contact-groups/$id/members/',
getParentRoute: () => rootRouteImport,
} as any)
const ApiV1AdminWhatsappGroupsJidIndexRoute =
ApiV1AdminWhatsappGroupsJidIndexRouteImport.update({
id: '/api/v1/admin/whatsapp-groups/$jid/',
path: '/api/v1/admin/whatsapp-groups/$jid/',
getParentRoute: () => rootRouteImport,
} as any)
const ApiV1AdminWebhooksIdIndexRoute =
ApiV1AdminWebhooksIdIndexRouteImport.update({
id: '/api/v1/admin/webhooks/$id/',
@ -594,7 +628,9 @@ export interface FileRoutesByFullPath {
'/api/v1/media/': typeof ApiV1MediaIndexRoute
'/api/v1/sessions/': typeof ApiV1SessionsIndexRoute
'/api/v1/webhooks/': typeof ApiV1WebhooksIndexRoute
'/api/v1/whatsapp-groups/': typeof ApiV1WhatsappGroupsIndexRoute
'/apps/$appId/contacts/': typeof AppsAppIdContactsIndexRoute
'/apps/$appId/groups/': typeof AppsAppIdGroupsIndexRoute
'/apps/$appId/sessions/': typeof AppsAppIdSessionsIndexRoute
'/apps/$appId/webhooks/': typeof AppsAppIdWebhooksIndexRoute
'/api/v1/admin/custom-field-schemas/$id': typeof ApiV1AdminCustomFieldSchemasIdRoute
@ -614,12 +650,14 @@ export interface FileRoutesByFullPath {
'/api/v1/admin/custom-field-schemas/': typeof ApiV1AdminCustomFieldSchemasIndexRoute
'/api/v1/admin/sessions/': typeof ApiV1AdminSessionsIndexRoute
'/api/v1/admin/webhooks/': typeof ApiV1AdminWebhooksIndexRoute
'/api/v1/admin/whatsapp-groups/': typeof ApiV1AdminWhatsappGroupsIndexRoute
'/api/v1/contact-groups/$id/': typeof ApiV1ContactGroupsIdIndexRoute
'/api/v1/contacts/$id/': typeof ApiV1ContactsIdIndexRoute
'/api/v1/contacts/import/': typeof ApiV1ContactsImportIndexRoute
'/api/v1/contacts/sync-from-whatsapp/': typeof ApiV1ContactsSyncFromWhatsappIndexRoute
'/api/v1/sessions/$id/': typeof ApiV1SessionsIdIndexRoute
'/api/v1/webhooks/$id/': typeof ApiV1WebhooksIdIndexRoute
'/api/v1/whatsapp-groups/$jid/': typeof ApiV1WhatsappGroupsJidIndexRoute
'/apps/$appId/contacts/groups/': typeof AppsAppIdContactsGroupsIndexRoute
'/apps/$appId/contacts/settings/': typeof AppsAppIdContactsSettingsIndexRoute
'/api/v1/admin/apps/$id/sync-contacts': typeof ApiV1AdminAppsIdSyncContactsRoute
@ -641,6 +679,7 @@ export interface FileRoutesByFullPath {
'/api/v1/admin/contacts/$id/': typeof ApiV1AdminContactsIdIndexRoute
'/api/v1/admin/sessions/$id/': typeof ApiV1AdminSessionsIdIndexRoute
'/api/v1/admin/webhooks/$id/': typeof ApiV1AdminWebhooksIdIndexRoute
'/api/v1/admin/whatsapp-groups/$jid/': typeof ApiV1AdminWhatsappGroupsJidIndexRoute
'/api/v1/contact-groups/$id/members/': typeof ApiV1ContactGroupsIdMembersIndexRoute
'/api/v1/sessions/$id/messages/': typeof ApiV1SessionsIdMessagesIndexRoute
'/api/v1/admin/apps/$id/api-keys/$keyId': typeof ApiV1AdminAppsIdApiKeysKeyIdRoute
@ -680,7 +719,9 @@ export interface FileRoutesByTo {
'/api/v1/media': typeof ApiV1MediaIndexRoute
'/api/v1/sessions': typeof ApiV1SessionsIndexRoute
'/api/v1/webhooks': typeof ApiV1WebhooksIndexRoute
'/api/v1/whatsapp-groups': typeof ApiV1WhatsappGroupsIndexRoute
'/apps/$appId/contacts': typeof AppsAppIdContactsIndexRoute
'/apps/$appId/groups': typeof AppsAppIdGroupsIndexRoute
'/apps/$appId/sessions': typeof AppsAppIdSessionsIndexRoute
'/apps/$appId/webhooks': typeof AppsAppIdWebhooksIndexRoute
'/api/v1/admin/custom-field-schemas/$id': typeof ApiV1AdminCustomFieldSchemasIdRoute
@ -700,12 +741,14 @@ export interface FileRoutesByTo {
'/api/v1/admin/custom-field-schemas': typeof ApiV1AdminCustomFieldSchemasIndexRoute
'/api/v1/admin/sessions': typeof ApiV1AdminSessionsIndexRoute
'/api/v1/admin/webhooks': typeof ApiV1AdminWebhooksIndexRoute
'/api/v1/admin/whatsapp-groups': typeof ApiV1AdminWhatsappGroupsIndexRoute
'/api/v1/contact-groups/$id': typeof ApiV1ContactGroupsIdIndexRoute
'/api/v1/contacts/$id': typeof ApiV1ContactsIdIndexRoute
'/api/v1/contacts/import': typeof ApiV1ContactsImportIndexRoute
'/api/v1/contacts/sync-from-whatsapp': typeof ApiV1ContactsSyncFromWhatsappIndexRoute
'/api/v1/sessions/$id': typeof ApiV1SessionsIdIndexRoute
'/api/v1/webhooks/$id': typeof ApiV1WebhooksIdIndexRoute
'/api/v1/whatsapp-groups/$jid': typeof ApiV1WhatsappGroupsJidIndexRoute
'/apps/$appId/contacts/groups': typeof AppsAppIdContactsGroupsIndexRoute
'/apps/$appId/contacts/settings': typeof AppsAppIdContactsSettingsIndexRoute
'/api/v1/admin/apps/$id/sync-contacts': typeof ApiV1AdminAppsIdSyncContactsRoute
@ -727,6 +770,7 @@ export interface FileRoutesByTo {
'/api/v1/admin/contacts/$id': typeof ApiV1AdminContactsIdIndexRoute
'/api/v1/admin/sessions/$id': typeof ApiV1AdminSessionsIdIndexRoute
'/api/v1/admin/webhooks/$id': typeof ApiV1AdminWebhooksIdIndexRoute
'/api/v1/admin/whatsapp-groups/$jid': typeof ApiV1AdminWhatsappGroupsJidIndexRoute
'/api/v1/contact-groups/$id/members': typeof ApiV1ContactGroupsIdMembersIndexRoute
'/api/v1/sessions/$id/messages': typeof ApiV1SessionsIdMessagesIndexRoute
'/api/v1/admin/apps/$id/api-keys/$keyId': typeof ApiV1AdminAppsIdApiKeysKeyIdRoute
@ -768,7 +812,9 @@ export interface FileRoutesById {
'/api/v1/media/': typeof ApiV1MediaIndexRoute
'/api/v1/sessions/': typeof ApiV1SessionsIndexRoute
'/api/v1/webhooks/': typeof ApiV1WebhooksIndexRoute
'/api/v1/whatsapp-groups/': typeof ApiV1WhatsappGroupsIndexRoute
'/apps/$appId/contacts/': typeof AppsAppIdContactsIndexRoute
'/apps/$appId/groups/': typeof AppsAppIdGroupsIndexRoute
'/apps/$appId/sessions/': typeof AppsAppIdSessionsIndexRoute
'/apps/$appId/webhooks/': typeof AppsAppIdWebhooksIndexRoute
'/api/v1/admin/custom-field-schemas/$id': typeof ApiV1AdminCustomFieldSchemasIdRoute
@ -788,12 +834,14 @@ export interface FileRoutesById {
'/api/v1/admin/custom-field-schemas/': typeof ApiV1AdminCustomFieldSchemasIndexRoute
'/api/v1/admin/sessions/': typeof ApiV1AdminSessionsIndexRoute
'/api/v1/admin/webhooks/': typeof ApiV1AdminWebhooksIndexRoute
'/api/v1/admin/whatsapp-groups/': typeof ApiV1AdminWhatsappGroupsIndexRoute
'/api/v1/contact-groups/$id/': typeof ApiV1ContactGroupsIdIndexRoute
'/api/v1/contacts/$id/': typeof ApiV1ContactsIdIndexRoute
'/api/v1/contacts/import/': typeof ApiV1ContactsImportIndexRoute
'/api/v1/contacts/sync-from-whatsapp/': typeof ApiV1ContactsSyncFromWhatsappIndexRoute
'/api/v1/sessions/$id/': typeof ApiV1SessionsIdIndexRoute
'/api/v1/webhooks/$id/': typeof ApiV1WebhooksIdIndexRoute
'/api/v1/whatsapp-groups/$jid/': typeof ApiV1WhatsappGroupsJidIndexRoute
'/apps/$appId/contacts/groups/': typeof AppsAppIdContactsGroupsIndexRoute
'/apps/$appId/contacts/settings/': typeof AppsAppIdContactsSettingsIndexRoute
'/api/v1/admin/apps/$id/sync-contacts': typeof ApiV1AdminAppsIdSyncContactsRoute
@ -815,6 +863,7 @@ export interface FileRoutesById {
'/api/v1/admin/contacts/$id/': typeof ApiV1AdminContactsIdIndexRoute
'/api/v1/admin/sessions/$id/': typeof ApiV1AdminSessionsIdIndexRoute
'/api/v1/admin/webhooks/$id/': typeof ApiV1AdminWebhooksIdIndexRoute
'/api/v1/admin/whatsapp-groups/$jid/': typeof ApiV1AdminWhatsappGroupsJidIndexRoute
'/api/v1/contact-groups/$id/members/': typeof ApiV1ContactGroupsIdMembersIndexRoute
'/api/v1/sessions/$id/messages/': typeof ApiV1SessionsIdMessagesIndexRoute
'/api/v1/admin/apps/$id/api-keys/$keyId': typeof ApiV1AdminAppsIdApiKeysKeyIdRoute
@ -857,7 +906,9 @@ export interface FileRouteTypes {
| '/api/v1/media/'
| '/api/v1/sessions/'
| '/api/v1/webhooks/'
| '/api/v1/whatsapp-groups/'
| '/apps/$appId/contacts/'
| '/apps/$appId/groups/'
| '/apps/$appId/sessions/'
| '/apps/$appId/webhooks/'
| '/api/v1/admin/custom-field-schemas/$id'
@ -877,12 +928,14 @@ export interface FileRouteTypes {
| '/api/v1/admin/custom-field-schemas/'
| '/api/v1/admin/sessions/'
| '/api/v1/admin/webhooks/'
| '/api/v1/admin/whatsapp-groups/'
| '/api/v1/contact-groups/$id/'
| '/api/v1/contacts/$id/'
| '/api/v1/contacts/import/'
| '/api/v1/contacts/sync-from-whatsapp/'
| '/api/v1/sessions/$id/'
| '/api/v1/webhooks/$id/'
| '/api/v1/whatsapp-groups/$jid/'
| '/apps/$appId/contacts/groups/'
| '/apps/$appId/contacts/settings/'
| '/api/v1/admin/apps/$id/sync-contacts'
@ -904,6 +957,7 @@ export interface FileRouteTypes {
| '/api/v1/admin/contacts/$id/'
| '/api/v1/admin/sessions/$id/'
| '/api/v1/admin/webhooks/$id/'
| '/api/v1/admin/whatsapp-groups/$jid/'
| '/api/v1/contact-groups/$id/members/'
| '/api/v1/sessions/$id/messages/'
| '/api/v1/admin/apps/$id/api-keys/$keyId'
@ -943,7 +997,9 @@ export interface FileRouteTypes {
| '/api/v1/media'
| '/api/v1/sessions'
| '/api/v1/webhooks'
| '/api/v1/whatsapp-groups'
| '/apps/$appId/contacts'
| '/apps/$appId/groups'
| '/apps/$appId/sessions'
| '/apps/$appId/webhooks'
| '/api/v1/admin/custom-field-schemas/$id'
@ -963,12 +1019,14 @@ export interface FileRouteTypes {
| '/api/v1/admin/custom-field-schemas'
| '/api/v1/admin/sessions'
| '/api/v1/admin/webhooks'
| '/api/v1/admin/whatsapp-groups'
| '/api/v1/contact-groups/$id'
| '/api/v1/contacts/$id'
| '/api/v1/contacts/import'
| '/api/v1/contacts/sync-from-whatsapp'
| '/api/v1/sessions/$id'
| '/api/v1/webhooks/$id'
| '/api/v1/whatsapp-groups/$jid'
| '/apps/$appId/contacts/groups'
| '/apps/$appId/contacts/settings'
| '/api/v1/admin/apps/$id/sync-contacts'
@ -990,6 +1048,7 @@ export interface FileRouteTypes {
| '/api/v1/admin/contacts/$id'
| '/api/v1/admin/sessions/$id'
| '/api/v1/admin/webhooks/$id'
| '/api/v1/admin/whatsapp-groups/$jid'
| '/api/v1/contact-groups/$id/members'
| '/api/v1/sessions/$id/messages'
| '/api/v1/admin/apps/$id/api-keys/$keyId'
@ -1030,7 +1089,9 @@ export interface FileRouteTypes {
| '/api/v1/media/'
| '/api/v1/sessions/'
| '/api/v1/webhooks/'
| '/api/v1/whatsapp-groups/'
| '/apps/$appId/contacts/'
| '/apps/$appId/groups/'
| '/apps/$appId/sessions/'
| '/apps/$appId/webhooks/'
| '/api/v1/admin/custom-field-schemas/$id'
@ -1050,12 +1111,14 @@ export interface FileRouteTypes {
| '/api/v1/admin/custom-field-schemas/'
| '/api/v1/admin/sessions/'
| '/api/v1/admin/webhooks/'
| '/api/v1/admin/whatsapp-groups/'
| '/api/v1/contact-groups/$id/'
| '/api/v1/contacts/$id/'
| '/api/v1/contacts/import/'
| '/api/v1/contacts/sync-from-whatsapp/'
| '/api/v1/sessions/$id/'
| '/api/v1/webhooks/$id/'
| '/api/v1/whatsapp-groups/$jid/'
| '/apps/$appId/contacts/groups/'
| '/apps/$appId/contacts/settings/'
| '/api/v1/admin/apps/$id/sync-contacts'
@ -1077,6 +1140,7 @@ export interface FileRouteTypes {
| '/api/v1/admin/contacts/$id/'
| '/api/v1/admin/sessions/$id/'
| '/api/v1/admin/webhooks/$id/'
| '/api/v1/admin/whatsapp-groups/$jid/'
| '/api/v1/contact-groups/$id/members/'
| '/api/v1/sessions/$id/messages/'
| '/api/v1/admin/apps/$id/api-keys/$keyId'
@ -1113,6 +1177,7 @@ export interface RootRouteChildren {
ApiV1MediaIndexRoute: typeof ApiV1MediaIndexRoute
ApiV1SessionsIndexRoute: typeof ApiV1SessionsIndexRoute
ApiV1WebhooksIndexRoute: typeof ApiV1WebhooksIndexRoute
ApiV1WhatsappGroupsIndexRoute: typeof ApiV1WhatsappGroupsIndexRoute
ApiV1AdminCustomFieldSchemasIdRoute: typeof ApiV1AdminCustomFieldSchemasIdRoute
ApiV1AdminMonitoringStatsRoute: typeof ApiV1AdminMonitoringStatsRoute
ApiV1ContactsImportJobIdRoute: typeof ApiV1ContactsImportJobIdRoute
@ -1129,12 +1194,14 @@ export interface RootRouteChildren {
ApiV1AdminCustomFieldSchemasIndexRoute: typeof ApiV1AdminCustomFieldSchemasIndexRoute
ApiV1AdminSessionsIndexRoute: typeof ApiV1AdminSessionsIndexRoute
ApiV1AdminWebhooksIndexRoute: typeof ApiV1AdminWebhooksIndexRoute
ApiV1AdminWhatsappGroupsIndexRoute: typeof ApiV1AdminWhatsappGroupsIndexRoute
ApiV1ContactGroupsIdIndexRoute: typeof ApiV1ContactGroupsIdIndexRoute
ApiV1ContactsIdIndexRoute: typeof ApiV1ContactsIdIndexRoute
ApiV1ContactsImportIndexRoute: typeof ApiV1ContactsImportIndexRoute
ApiV1ContactsSyncFromWhatsappIndexRoute: typeof ApiV1ContactsSyncFromWhatsappIndexRoute
ApiV1SessionsIdIndexRoute: typeof ApiV1SessionsIdIndexRoute
ApiV1WebhooksIdIndexRoute: typeof ApiV1WebhooksIdIndexRoute
ApiV1WhatsappGroupsJidIndexRoute: typeof ApiV1WhatsappGroupsJidIndexRoute
ApiV1AdminAppsIdSyncContactsRoute: typeof ApiV1AdminAppsIdSyncContactsRoute
ApiV1AdminContactsIdTagsRoute: typeof ApiV1AdminContactsIdTagsRoute
ApiV1AdminSessionsIdDisconnectRoute: typeof ApiV1AdminSessionsIdDisconnectRoute
@ -1154,6 +1221,7 @@ export interface RootRouteChildren {
ApiV1AdminContactsIdIndexRoute: typeof ApiV1AdminContactsIdIndexRoute
ApiV1AdminSessionsIdIndexRoute: typeof ApiV1AdminSessionsIdIndexRoute
ApiV1AdminWebhooksIdIndexRoute: typeof ApiV1AdminWebhooksIdIndexRoute
ApiV1AdminWhatsappGroupsJidIndexRoute: typeof ApiV1AdminWhatsappGroupsJidIndexRoute
ApiV1ContactGroupsIdMembersIndexRoute: typeof ApiV1ContactGroupsIdMembersIndexRoute
ApiV1SessionsIdMessagesIndexRoute: typeof ApiV1SessionsIdMessagesIndexRoute
ApiV1AdminAppsIdApiKeysKeyIdRoute: typeof ApiV1AdminAppsIdApiKeysKeyIdRoute
@ -1299,6 +1367,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AppsAppIdSessionsIndexRouteImport
parentRoute: typeof AppsAppIdRoute
}
'/apps/$appId/groups/': {
id: '/apps/$appId/groups/'
path: '/groups'
fullPath: '/apps/$appId/groups/'
preLoaderRoute: typeof AppsAppIdGroupsIndexRouteImport
parentRoute: typeof AppsAppIdRoute
}
'/apps/$appId/contacts/': {
id: '/apps/$appId/contacts/'
path: '/contacts'
@ -1306,6 +1381,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AppsAppIdContactsIndexRouteImport
parentRoute: typeof AppsAppIdRoute
}
'/api/v1/whatsapp-groups/': {
id: '/api/v1/whatsapp-groups/'
path: '/api/v1/whatsapp-groups'
fullPath: '/api/v1/whatsapp-groups/'
preLoaderRoute: typeof ApiV1WhatsappGroupsIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/api/v1/webhooks/': {
id: '/api/v1/webhooks/'
path: '/api/v1/webhooks'
@ -1411,6 +1493,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AppsAppIdContactsGroupsIndexRouteImport
parentRoute: typeof AppsAppIdRoute
}
'/api/v1/whatsapp-groups/$jid/': {
id: '/api/v1/whatsapp-groups/$jid/'
path: '/api/v1/whatsapp-groups/$jid'
fullPath: '/api/v1/whatsapp-groups/$jid/'
preLoaderRoute: typeof ApiV1WhatsappGroupsJidIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/api/v1/webhooks/$id/': {
id: '/api/v1/webhooks/$id/'
path: '/api/v1/webhooks/$id'
@ -1453,6 +1542,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiV1ContactGroupsIdIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/api/v1/admin/whatsapp-groups/': {
id: '/api/v1/admin/whatsapp-groups/'
path: '/api/v1/admin/whatsapp-groups'
fullPath: '/api/v1/admin/whatsapp-groups/'
preLoaderRoute: typeof ApiV1AdminWhatsappGroupsIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/api/v1/admin/webhooks/': {
id: '/api/v1/admin/webhooks/'
path: '/api/v1/admin/webhooks'
@ -1586,6 +1682,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof ApiV1ContactGroupsIdMembersIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/api/v1/admin/whatsapp-groups/$jid/': {
id: '/api/v1/admin/whatsapp-groups/$jid/'
path: '/api/v1/admin/whatsapp-groups/$jid'
fullPath: '/api/v1/admin/whatsapp-groups/$jid/'
preLoaderRoute: typeof ApiV1AdminWhatsappGroupsJidIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/api/v1/admin/webhooks/$id/': {
id: '/api/v1/admin/webhooks/$id/'
path: '/api/v1/admin/webhooks/$id'
@ -1771,6 +1874,7 @@ interface AppsAppIdRouteChildren {
AppsAppIdSessionsSessionIdRoute: typeof AppsAppIdSessionsSessionIdRoute
AppsAppIdSessionsNewRoute: typeof AppsAppIdSessionsNewRoute
AppsAppIdContactsIndexRoute: typeof AppsAppIdContactsIndexRoute
AppsAppIdGroupsIndexRoute: typeof AppsAppIdGroupsIndexRoute
AppsAppIdSessionsIndexRoute: typeof AppsAppIdSessionsIndexRoute
AppsAppIdWebhooksIndexRoute: typeof AppsAppIdWebhooksIndexRoute
AppsAppIdWebhooksWebhookIdDeliveriesRoute: typeof AppsAppIdWebhooksWebhookIdDeliveriesRoute
@ -1785,6 +1889,7 @@ const AppsAppIdRouteChildren: AppsAppIdRouteChildren = {
AppsAppIdSessionsSessionIdRoute: AppsAppIdSessionsSessionIdRoute,
AppsAppIdSessionsNewRoute: AppsAppIdSessionsNewRoute,
AppsAppIdContactsIndexRoute: AppsAppIdContactsIndexRoute,
AppsAppIdGroupsIndexRoute: AppsAppIdGroupsIndexRoute,
AppsAppIdSessionsIndexRoute: AppsAppIdSessionsIndexRoute,
AppsAppIdWebhooksIndexRoute: AppsAppIdWebhooksIndexRoute,
AppsAppIdWebhooksWebhookIdDeliveriesRoute:
@ -1823,6 +1928,7 @@ const rootRouteChildren: RootRouteChildren = {
ApiV1MediaIndexRoute: ApiV1MediaIndexRoute,
ApiV1SessionsIndexRoute: ApiV1SessionsIndexRoute,
ApiV1WebhooksIndexRoute: ApiV1WebhooksIndexRoute,
ApiV1WhatsappGroupsIndexRoute: ApiV1WhatsappGroupsIndexRoute,
ApiV1AdminCustomFieldSchemasIdRoute: ApiV1AdminCustomFieldSchemasIdRoute,
ApiV1AdminMonitoringStatsRoute: ApiV1AdminMonitoringStatsRoute,
ApiV1ContactsImportJobIdRoute: ApiV1ContactsImportJobIdRoute,
@ -1840,6 +1946,7 @@ const rootRouteChildren: RootRouteChildren = {
ApiV1AdminCustomFieldSchemasIndexRoute,
ApiV1AdminSessionsIndexRoute: ApiV1AdminSessionsIndexRoute,
ApiV1AdminWebhooksIndexRoute: ApiV1AdminWebhooksIndexRoute,
ApiV1AdminWhatsappGroupsIndexRoute: ApiV1AdminWhatsappGroupsIndexRoute,
ApiV1ContactGroupsIdIndexRoute: ApiV1ContactGroupsIdIndexRoute,
ApiV1ContactsIdIndexRoute: ApiV1ContactsIdIndexRoute,
ApiV1ContactsImportIndexRoute: ApiV1ContactsImportIndexRoute,
@ -1847,6 +1954,7 @@ const rootRouteChildren: RootRouteChildren = {
ApiV1ContactsSyncFromWhatsappIndexRoute,
ApiV1SessionsIdIndexRoute: ApiV1SessionsIdIndexRoute,
ApiV1WebhooksIdIndexRoute: ApiV1WebhooksIdIndexRoute,
ApiV1WhatsappGroupsJidIndexRoute: ApiV1WhatsappGroupsJidIndexRoute,
ApiV1AdminAppsIdSyncContactsRoute: ApiV1AdminAppsIdSyncContactsRoute,
ApiV1AdminContactsIdTagsRoute: ApiV1AdminContactsIdTagsRoute,
ApiV1AdminSessionsIdDisconnectRoute: ApiV1AdminSessionsIdDisconnectRoute,
@ -1867,6 +1975,7 @@ const rootRouteChildren: RootRouteChildren = {
ApiV1AdminContactsIdIndexRoute: ApiV1AdminContactsIdIndexRoute,
ApiV1AdminSessionsIdIndexRoute: ApiV1AdminSessionsIdIndexRoute,
ApiV1AdminWebhooksIdIndexRoute: ApiV1AdminWebhooksIdIndexRoute,
ApiV1AdminWhatsappGroupsJidIndexRoute: ApiV1AdminWhatsappGroupsJidIndexRoute,
ApiV1ContactGroupsIdMembersIndexRoute: ApiV1ContactGroupsIdMembersIndexRoute,
ApiV1SessionsIdMessagesIndexRoute: ApiV1SessionsIdMessagesIndexRoute,
ApiV1AdminAppsIdApiKeysKeyIdRoute: ApiV1AdminAppsIdApiKeysKeyIdRoute,

View file

@ -0,0 +1,28 @@
import { createFileRoute } from "@tanstack/react-router";
import { json, jsonError, readSession } from "#/lib/session-api";
import { getWhatsappGroup } from "#/lib/actions/whatsapp-groups";
export const Route = createFileRoute("/api/v1/admin/whatsapp-groups/$jid/")({
server: {
handlers: {
GET: async ({ request, params }) => {
const session = await readSession(request);
if (!session?.userId) return jsonError("Unauthorized", 401);
const url = new URL(request.url);
const appId = url.searchParams.get("app_id");
const sessionId = url.searchParams.get("session_id");
if (!appId) return jsonError("app_id required", 400);
if (!sessionId) return jsonError("session_id required", 400);
const group = await getWhatsappGroup(
decodeURIComponent(params.jid),
sessionId,
appId,
);
if (!group) return jsonError("Group not found", 404);
return json(group);
},
},
},
});

View file

@ -0,0 +1,33 @@
import { createFileRoute } from "@tanstack/react-router";
import { json, jsonError, readSession } from "#/lib/session-api";
import { listWhatsappGroups } from "#/lib/actions/whatsapp-groups";
import { db } from "#/db/index";
import { apps } from "#/db/schema";
import { eq } from "drizzle-orm";
export const Route = createFileRoute("/api/v1/admin/whatsapp-groups/")({
server: {
handlers: {
GET: async ({ request }) => {
const session = await readSession(request);
if (!session?.userId) return jsonError("Unauthorized", 401);
const url = new URL(request.url);
const appId = url.searchParams.get("app_id");
const sessionId = url.searchParams.get("session_id") ?? undefined;
if (!appId) return jsonError("app_id required", 400);
const appRows = await db
.select({ id: apps.id })
.from(apps)
.where(eq(apps.id, appId))
.limit(1);
if (!appRows.length) return jsonError("App not found", 404);
const groups = await listWhatsappGroups(appId, sessionId);
return json(groups);
},
},
},
});

View file

@ -0,0 +1,27 @@
import { createFileRoute } from "@tanstack/react-router";
import { authenticateApiKey } from "#/lib/api-key-auth";
import { json, jsonError } from "#/lib/session-api";
import { getWhatsappGroup } from "#/lib/actions/whatsapp-groups";
export const Route = createFileRoute("/api/v1/whatsapp-groups/$jid/")({
server: {
handlers: {
GET: async ({ request, params }) => {
const auth = await authenticateApiKey(request);
if (auth instanceof Response) return auth;
const url = new URL(request.url);
const sessionId = url.searchParams.get("session_id");
if (!sessionId) return jsonError("session_id required", 400);
const group = await getWhatsappGroup(
decodeURIComponent(params.jid),
sessionId,
auth.appId,
);
if (!group) return jsonError("Group not found", 404);
return json(group);
},
},
},
});

View file

@ -0,0 +1,23 @@
import { createFileRoute } from "@tanstack/react-router";
import { authenticateApiKey } from "#/lib/api-key-auth";
import { json, jsonError } from "#/lib/session-api";
import { listWhatsappGroups } from "#/lib/actions/whatsapp-groups";
export const Route = createFileRoute("/api/v1/whatsapp-groups/")({
server: {
handlers: {
GET: async ({ request }) => {
const auth = await authenticateApiKey(request);
if (auth instanceof Response) return auth;
const url = new URL(request.url);
const sessionId = url.searchParams.get("session_id") ?? undefined;
if (!sessionId) return jsonError("session_id required", 400);
const groups = await listWhatsappGroups(auth.appId, sessionId);
return json(groups);
},
},
},
});

View file

@ -0,0 +1,206 @@
import { createFileRoute } from "@tanstack/react-router";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { Users2, ChevronDown } from "lucide-react";
import { whatsappGroupsQuery, appSessionsQuery } from "#/lib/query";
import { cn } from "#/lib/utils";
export const Route = createFileRoute("/apps/$appId/groups/")({
component: GroupsPage,
});
interface WhatsappGroup {
id: string;
appId: string;
sessionId: string;
jid: string;
subject: string | null;
description: string | null;
participantCount: number | null;
createdAt: string;
updatedAt: string;
}
interface SessionRow {
id: string;
appId: string;
appName: string;
status: string;
}
function timeAgo(iso: string): string {
const diff = Date.now() - new Date(iso).getTime();
const m = Math.floor(diff / 60000);
const h = Math.floor(m / 60);
const d = Math.floor(h / 24);
if (m < 1) return "just now";
if (m < 60) return `${m}m ago`;
if (h < 24) return `${h}h ago`;
if (d < 30) return `${d}d ago`;
return new Date(iso).toLocaleDateString(undefined, { month: "short", day: "numeric", year: "numeric" });
}
function splitJid(jid: string) {
const at = jid.indexOf("@");
if (at === -1) return { num: jid, domain: "" };
return { num: jid.slice(0, at), domain: "@" + jid.slice(at + 1) };
}
function SkeletonRow() {
return (
<div className="rounded-xl border border-gray-100 bg-white p-4 animate-pulse">
<div className="flex items-center justify-between gap-4">
<div className="flex-1 space-y-2">
<div className="h-4 w-48 rounded bg-gray-100" />
<div className="h-3 w-64 rounded bg-gray-100" />
</div>
<div className="h-5 w-12 rounded-full bg-gray-100" />
</div>
</div>
);
}
function GroupsPage() {
const { appId } = Route.useParams();
const [sessionFilter, setSessionFilter] = useState<string>("");
const { data: sessions = [] } = useQuery<SessionRow[]>(appSessionsQuery(appId));
const { data: groups = [], isPending } = useQuery<WhatsappGroup[]>(
whatsappGroupsQuery(appId, sessionFilter || undefined),
);
const sessionMap = Object.fromEntries(sessions.map((s) => [s.id, s]));
return (
<div className="min-h-full bg-gray-50/50">
{/* Header */}
<div className="bg-white border-b border-gray-200 px-8 h-14 flex items-center justify-between">
<div className="flex items-center gap-2">
<Users2 size={15} className="text-gray-400" strokeWidth={1.75} />
<h1 className="text-[14px] font-semibold text-gray-900">Groups</h1>
{!(isPending as boolean) && groups.length > 0 && (
<span className="text-[11px] font-medium text-gray-400 bg-gray-100 rounded-full px-2 py-0.5">
{groups.length}
</span>
)}
</div>
{/* Session filter */}
{sessions.length > 1 && (
<div className="relative">
<select
value={sessionFilter}
onChange={(e) => setSessionFilter(e.target.value)}
className="appearance-none text-[12px] text-gray-600 bg-white border border-gray-200 rounded-lg pl-3 pr-7 py-1.5 focus:outline-none focus:ring-1 focus:ring-zinc-300 cursor-pointer"
>
<option value="">All sessions</option>
{sessions.map((s) => (
<option key={s.id} value={s.id}>
{s.id.slice(0, 8)}… ({s.status})
</option>
))}
</select>
<ChevronDown size={11} className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400 pointer-events-none" />
</div>
)}
</div>
<div className="px-8 py-8">
{/* Loading */}
{(isPending as boolean) && (
<div className="space-y-2.5">
<SkeletonRow />
<SkeletonRow />
<SkeletonRow />
</div>
)}
{/* Empty state */}
{!(isPending as boolean) && groups.length === 0 && (
<div className="flex flex-col items-center justify-center py-24 text-center">
<div className="mb-4 flex items-center justify-center w-14 h-14 rounded-2xl bg-gray-100 text-gray-300">
<Users2 size={28} strokeWidth={1.25} />
</div>
<h2 className="text-[15px] font-semibold text-gray-800 mb-1">No groups tracked yet</h2>
<p className="text-[13px] text-gray-400 max-w-xs leading-relaxed">
Groups appear here automatically when messages are received from WhatsApp group chats.
</p>
</div>
)}
{/* Group cards */}
<div className="space-y-2.5">
{groups.map((g) => {
const { num, domain } = splitJid(g.jid);
const session = sessionMap[g.sessionId];
return (
<div
key={g.id}
className="rounded-xl border border-gray-100 bg-white overflow-hidden hover:shadow-sm transition-shadow"
>
<div className="flex">
<div className="w-0.5 shrink-0 bg-violet-300" />
<div className="flex-1 px-4 py-3.5">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
{/* Subject + JID */}
<div className="flex items-center gap-2 mb-1">
{g.subject ? (
<span className="text-[13px] font-semibold text-gray-900 truncate">
{g.subject}
</span>
) : (
<span className="text-[13px] text-gray-400 italic">Unnamed group</span>
)}
</div>
<div className="flex items-center gap-2 mb-2">
<span className="font-mono text-[11px] text-gray-400">
{num}
<span className="text-gray-300">{domain}</span>
</span>
</div>
{/* Description */}
{g.description && (
<p className="text-[12px] text-gray-500 mb-2 line-clamp-2 leading-relaxed">
{g.description}
</p>
)}
{/* Meta row */}
<div className="flex items-center gap-3 text-[11px] text-gray-400">
{g.participantCount !== null && (
<span className="flex items-center gap-1">
<Users2 size={10} />
{g.participantCount} participant{g.participantCount !== 1 ? "s" : ""}
</span>
)}
{session && (
<span
className={cn(
"inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-mono border",
session.status === "CONNECTED"
? "bg-emerald-50 border-emerald-100 text-emerald-600"
: "bg-gray-50 border-gray-100 text-gray-500",
)}
>
{g.sessionId.slice(0, 8)}…
</span>
)}
<span title={new Date(g.updatedAt).toLocaleString()}>
updated {timeAgo(g.updatedAt)}
</span>
</div>
</div>
</div>
</div>
</div>
</div>
);
})}
</div>
</div>
</div>
);
}

View file

@ -117,7 +117,7 @@ function DeliveriesPage() {
<ScrollText size={14} className="text-gray-400" strokeWidth={1.75} />
<h1 className="text-[14px] font-semibold text-gray-900">Delivery Log</h1>
</div>
{!isPending && deliveries.length > 0 && (
{!(isPending as boolean) && deliveries.length > 0 && (
<span className="text-[11px] font-medium text-gray-400 bg-gray-100 rounded-full px-2 py-0.5">
{deliveries.length}
</span>

View file

@ -58,6 +58,11 @@ const ALL_EVENTS = [
"presence.update",
"contact.created",
"contact.updated",
"group.joined",
"group.left",
"group.updated",
"group.participants_added",
"group.participants_removed",
];
function formatDate(iso: string) {
@ -128,7 +133,7 @@ function WebhooksPage() {
const [deleting, setDeleting] = useState(false);
const [testingId, setTestingId] = useState<string | null>(null);
const [testResults, setTestResults] = useState<Record<string, TestResult>>({});
const [testResults, setTestResults] = useState<Partial<Record<string, TestResult>>>({});
function toggleEvent(ev: string) {
setNewEvents((prev) =>

View file

@ -0,0 +1,344 @@
/**
* Critical system unit tests — pure logic, no DB/network/Redis required.
*
* Coverage:
* - API key generation: format, prefix, env prefix, uniqueness
* - Webhook URL validation: invalid URL, bad protocol, SSRF private IP block,
* production HTTPS enforcement, private allowed in non-prod
* - Key rotation guard: concurrent rotation prevention conditions
* - API key revocation idempotency: 409 on double-revoke
* - Broadcast recipient cap (1000)
* - File upload size limit (10 MB)
*/
import { describe, it, expect } from "vitest";
import { randomBytes } from "node:crypto";
// ─── API key generation ───────────────────────────────────────────────────────
function generateApiKey(env: "live" | "test" = "live"): { key: string; prefix: string } {
const rand = randomBytes(24).toString("base64url");
const key = `wak_${env}_${rand}`;
const prefix = key.slice(-4);
return { key, prefix };
}
describe("API key generation", () => {
it("live key starts with wak_live_", () => {
const { key } = generateApiKey("live");
expect(key).toMatch(/^wak_live_/);
});
it("test key starts with wak_test_", () => {
const { key } = generateApiKey("test");
expect(key).toMatch(/^wak_test_/);
});
it("prefix is last 4 characters of key", () => {
const { key, prefix } = generateApiKey("live");
expect(prefix).toBe(key.slice(-4));
expect(prefix).toHaveLength(4);
});
it("random portion is base64url safe (no +/=)", () => {
for (let i = 0; i < 50; i++) {
const { key } = generateApiKey("live");
const rand = key.replace(/^wak_live_/, "");
expect(rand).toMatch(/^[A-Za-z0-9_-]+$/);
}
});
it("each call produces unique key", () => {
const keys = new Set(Array.from({ length: 200 }, () => generateApiKey("live").key));
expect(keys.size).toBe(200);
});
it("default env is live", () => {
const { key } = generateApiKey();
expect(key).toMatch(/^wak_live_/);
});
it("key total length is consistent", () => {
// wak_live_ (9) + base64url(24 bytes) = 9 + 32 = 41
const { key } = generateApiKey("live");
expect(key.length).toBeGreaterThan(20);
});
});
// ─── Webhook URL validation ───────────────────────────────────────────────────
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,
env: "production" | "development" | "test" = "test",
): { 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 (env === "production" && parsed.protocol !== "https:") {
return { ok: false, error: "URL must use https in production" };
}
return { ok: true };
}
function isPrivateHostname(hostname: string): boolean {
return BLOCKED_HOSTNAMES.test(hostname);
}
describe("webhook URL validation — basic", () => {
it("valid https URL passes", () => {
expect(validateWebhookUrl("https://example.com/webhook")).toEqual({ ok: true });
});
it("valid http URL passes in non-prod", () => {
expect(validateWebhookUrl("http://example.com/webhook", "development")).toEqual({ ok: true });
});
it("invalid URL rejected", () => {
const r = validateWebhookUrl("not-a-url");
expect(r.ok).toBe(false);
expect((r as { ok: false; error: string }).error).toMatch(/invalid url/i);
});
it("ftp:// protocol rejected", () => {
const r = validateWebhookUrl("ftp://example.com/hook");
expect(r.ok).toBe(false);
});
it("ws:// protocol rejected", () => {
const r = validateWebhookUrl("ws://example.com/hook");
expect(r.ok).toBe(false);
});
it("empty string rejected", () => {
expect(validateWebhookUrl("").ok).toBe(false);
});
});
describe("webhook URL validation — production HTTPS enforcement", () => {
it("http rejected in production", () => {
const r = validateWebhookUrl("http://example.com/webhook", "production");
expect(r.ok).toBe(false);
expect((r as { ok: false; error: string }).error).toMatch(/https/i);
});
it("https accepted in production", () => {
expect(validateWebhookUrl("https://example.com/webhook", "production")).toEqual({ ok: true });
});
it("http accepted in development", () => {
expect(validateWebhookUrl("http://example.com/webhook", "development")).toEqual({ ok: true });
});
});
describe("SSRF — private hostname detection", () => {
const blocked = [
"localhost",
"LOCALHOST",
"127.0.0.1",
"127.1.2.3",
"0.0.0.0",
"::1",
"10.0.0.1",
"10.255.255.255",
"172.16.0.1",
"172.20.5.5",
"172.31.255.255",
"192.168.0.1",
"192.168.1.100",
"169.254.0.1",
"169.254.169.254", // AWS metadata
];
const allowed = [
"example.com",
"api.example.com",
"172.15.0.1", // just outside 172.16-31 range
"172.32.0.1", // just outside 172.16-31 range
"192.169.0.1", // not 192.168
"11.0.0.1", // not 10.x
"8.8.8.8",
];
for (const host of blocked) {
it(`blocks ${host}`, () => {
expect(isPrivateHostname(host)).toBe(true);
});
}
for (const host of allowed) {
it(`allows ${host}`, () => {
expect(isPrivateHostname(host)).toBe(false);
});
}
});
// ─── Key rotation guard ───────────────────────────────────────────────────────
type RotationStatus = "pending" | "running" | "done" | "failed" | null;
function canStartRotation(currentStatus: RotationStatus): boolean {
return currentStatus !== "pending" && currentStatus !== "running";
}
describe("key rotation concurrency guard", () => {
it("null status → can start", () => {
expect(canStartRotation(null)).toBe(true);
});
it("done status → can start", () => {
expect(canStartRotation("done")).toBe(true);
});
it("failed status → can start (retry allowed)", () => {
expect(canStartRotation("failed")).toBe(true);
});
it("pending status → cannot start (already queued)", () => {
expect(canStartRotation("pending")).toBe(false);
});
it("running status → cannot start (in progress)", () => {
expect(canStartRotation("running")).toBe(false);
});
});
// ─── API key revocation idempotency ──────────────────────────────────────────
interface FakeApiKey {
id: string;
appId: string;
revokedAt: Date | null;
}
function revokeApiKey(
key: FakeApiKey | null,
requestedAppId: string,
): { ok: true } | { ok: false; error: string; status: number } {
if (!key || key.appId !== requestedAppId) {
return { ok: false, error: "Key not found", status: 404 };
}
if (key.revokedAt) {
return { ok: false, error: "Key already revoked", status: 409 };
}
key.revokedAt = new Date();
return { ok: true };
}
describe("API key revocation idempotency", () => {
function makeKey(appId = "app1"): FakeApiKey {
return { id: "key1", appId, revokedAt: null };
}
it("first revocation succeeds", () => {
const key = makeKey();
expect(revokeApiKey(key, "app1")).toEqual({ ok: true });
});
it("second revocation → 409", () => {
const key = makeKey();
revokeApiKey(key, "app1");
const r = revokeApiKey(key, "app1");
expect(r.ok).toBe(false);
expect((r as { ok: false; error: string; status: number }).status).toBe(409);
});
it("key not found → 404", () => {
const r = revokeApiKey(null, "app1");
expect(r.ok).toBe(false);
expect((r as { ok: false; error: string; status: number }).status).toBe(404);
});
it("wrong appId → 404 (cross-app isolation)", () => {
const key = makeKey("app1");
const r = revokeApiKey(key, "app2");
expect(r.ok).toBe(false);
expect((r as { ok: false; error: string; status: number }).status).toBe(404);
});
it("revocation sets revokedAt timestamp", () => {
const key = makeKey();
const before = Date.now();
revokeApiKey(key, "app1");
expect(key.revokedAt).toBeInstanceOf(Date);
expect(key.revokedAt!.getTime()).toBeGreaterThanOrEqual(before);
});
});
// ─── Broadcast recipient cap ──────────────────────────────────────────────────
const BROADCAST_MAX = 1000;
function validateBroadcastRecipients(
recipients: string[],
): { ok: true } | { ok: false; error: string } {
if (recipients.length === 0) return { ok: false, error: "No recipients" };
if (recipients.length > BROADCAST_MAX) {
return { ok: false, error: `Recipient count ${recipients.length} exceeds maximum of ${BROADCAST_MAX}` };
}
return { ok: true };
}
describe("broadcast recipient cap", () => {
it("1 recipient → ok", () => {
expect(validateBroadcastRecipients(["a"])).toEqual({ ok: true });
});
it("1000 recipients → ok (at limit)", () => {
const r = Array.from({ length: 1000 }, (_, i) => `${i}@s.whatsapp.net`);
expect(validateBroadcastRecipients(r)).toEqual({ ok: true });
});
it("1001 recipients → rejected", () => {
const r = Array.from({ length: 1001 }, (_, i) => `${i}@s.whatsapp.net`);
const result = validateBroadcastRecipients(r);
expect(result.ok).toBe(false);
expect((result as { ok: false; error: string }).error).toMatch(/1001/);
});
it("empty list → rejected", () => {
expect(validateBroadcastRecipients([])).toEqual({ ok: false, error: "No recipients" });
});
});
// ─── File upload size limit ───────────────────────────────────────────────────
const MAX_UPLOAD_BYTES = 10 * 1024 * 1024; // 10 MB
function validateUploadSize(bytes: number): { ok: true } | { ok: false; error: string } {
if (bytes > MAX_UPLOAD_BYTES) {
return { ok: false, error: `File too large: ${bytes} bytes (max ${MAX_UPLOAD_BYTES})` };
}
return { ok: true };
}
describe("contact import file size limit (10 MB)", () => {
it("exactly 10 MB → ok", () => {
expect(validateUploadSize(MAX_UPLOAD_BYTES)).toEqual({ ok: true });
});
it("10 MB + 1 byte → rejected", () => {
expect(validateUploadSize(MAX_UPLOAD_BYTES + 1).ok).toBe(false);
});
it("0 bytes → ok (let content validation handle empty)", () => {
expect(validateUploadSize(0)).toEqual({ ok: true });
});
it("5 MB → ok", () => {
expect(validateUploadSize(5 * 1024 * 1024)).toEqual({ ok: true });
});
it("error message includes actual and max size", () => {
const r = validateUploadSize(MAX_UPLOAD_BYTES + 1) as { ok: false; error: string };
expect(r.error).toMatch(/10485761/);
expect(r.error).toMatch(/10485760/);
});
});

View file

@ -0,0 +1,524 @@
/**
* WhatsApp groups unit tests — pure logic, no DB/network required.
*
* Coverage:
* - JID group detection (@g.us suffix)
* - upsert merge logic (partial updates, undefined fields not clobbered)
* - adjustParticipantCount: add/remove deltas, floor at 0
* - listWhatsappGroups filter: by appId, by sessionId, unfiltered
* - Group subject/description normalization
* - Webhook event routing: ownAdded/ownRemoved/other add/remove
* - Webhook payload shapes for all 5 group event types
* - Own JID matching: exact, LID prefix (:0 suffix stripped)
*/
import { describe, it, expect } from "vitest";
// ─── JID group detection ──────────────────────────────────────────────────────
function isGroupJid(jid: string): boolean {
return jid.endsWith("@g.us");
}
describe("JID group detection", () => {
it("@g.us JID is a group", () => {
expect(isGroupJid("120363429229698693@g.us")).toBe(true);
});
it("@s.whatsapp.net JID is not a group", () => {
expect(isGroupJid("6281234567890@s.whatsapp.net")).toBe(false);
});
it("empty string is not a group", () => {
expect(isGroupJid("")).toBe(false);
});
it("partial @g.us without domain prefix is not a group", () => {
expect(isGroupJid("@g.us")).toBe(true); // ends with @g.us — still truthy by rule
});
it("random string without suffix is not a group", () => {
expect(isGroupJid("somegroup")).toBe(false);
});
});
// ─── Upsert merge logic ───────────────────────────────────────────────────────
interface FakeGroup {
jid: string;
sessionId: string;
appId: string;
subject: string | null;
description: string | null;
participantCount: number | null;
updatedAt: Date;
}
function upsertGroup(
store: Map<string, FakeGroup>,
data: {
appId: string;
sessionId: string;
jid: string;
subject?: string | null;
description?: string | null;
participantCount?: number | null;
},
): FakeGroup {
const key = `${data.sessionId}:${data.jid}`;
const existing = store.get(key);
const now = new Date();
if (!existing) {
const row: FakeGroup = {
jid: data.jid,
sessionId: data.sessionId,
appId: data.appId,
subject: data.subject ?? null,
description: data.description ?? null,
participantCount: data.participantCount ?? null,
updatedAt: now,
};
store.set(key, row);
return row;
}
if (data.subject !== undefined) existing.subject = data.subject;
if (data.description !== undefined) existing.description = data.description;
if (data.participantCount !== undefined) existing.participantCount = data.participantCount;
existing.updatedAt = now;
return existing;
}
describe("upsert merge logic", () => {
it("inserts new group", () => {
const store = new Map<string, FakeGroup>();
const g = upsertGroup(store, {
appId: "app1",
sessionId: "sess1",
jid: "123@g.us",
subject: "Dev Team",
participantCount: 5,
});
expect(g.subject).toBe("Dev Team");
expect(g.participantCount).toBe(5);
expect(store.size).toBe(1);
});
it("updates subject on re-upsert", () => {
const store = new Map<string, FakeGroup>();
upsertGroup(store, { appId: "a", sessionId: "s", jid: "123@g.us", subject: "Old" });
const g = upsertGroup(store, { appId: "a", sessionId: "s", jid: "123@g.us", subject: "New" });
expect(g.subject).toBe("New");
expect(store.size).toBe(1);
});
it("undefined subject does not clobber existing subject", () => {
const store = new Map<string, FakeGroup>();
upsertGroup(store, { appId: "a", sessionId: "s", jid: "123@g.us", subject: "Keep me" });
const g = upsertGroup(store, { appId: "a", sessionId: "s", jid: "123@g.us", description: "new desc" });
expect(g.subject).toBe("Keep me");
expect(g.description).toBe("new desc");
});
it("null subject explicitly clears subject", () => {
const store = new Map<string, FakeGroup>();
upsertGroup(store, { appId: "a", sessionId: "s", jid: "123@g.us", subject: "Has Name" });
const g = upsertGroup(store, { appId: "a", sessionId: "s", jid: "123@g.us", subject: null });
expect(g.subject).toBeNull();
});
it("different sessions create separate entries for same JID", () => {
const store = new Map<string, FakeGroup>();
upsertGroup(store, { appId: "a", sessionId: "s1", jid: "123@g.us", subject: "A" });
upsertGroup(store, { appId: "a", sessionId: "s2", jid: "123@g.us", subject: "B" });
expect(store.size).toBe(2);
});
});
// ─── adjustParticipantCount ───────────────────────────────────────────────────
function adjustCount(current: number | null, delta: number): number {
return Math.max(0, (current ?? 0) + delta);
}
describe("adjustParticipantCount delta", () => {
it("add participants increases count", () => {
expect(adjustCount(5, 2)).toBe(7);
});
it("remove participants decreases count", () => {
expect(adjustCount(5, -2)).toBe(3);
});
it("remove more than current clamps to 0", () => {
expect(adjustCount(2, -10)).toBe(0);
});
it("null current treated as 0", () => {
expect(adjustCount(null, 3)).toBe(3);
});
it("remove from null/0 clamps to 0", () => {
expect(adjustCount(null, -5)).toBe(0);
});
it("zero delta leaves count unchanged", () => {
expect(adjustCount(8, 0)).toBe(8);
});
it("large add works correctly", () => {
expect(adjustCount(100, 50)).toBe(150);
});
});
// ─── listWhatsappGroups filter ────────────────────────────────────────────────
interface GroupRow {
id: string;
appId: string;
sessionId: string;
jid: string;
subject: string | null;
}
function listGroups(
rows: GroupRow[],
appId: string,
sessionId?: string,
): GroupRow[] {
return rows.filter(
(r) => r.appId === appId && (sessionId ? r.sessionId === sessionId : true),
);
}
describe("listWhatsappGroups filter", () => {
const rows: GroupRow[] = [
{ id: "1", appId: "app1", sessionId: "sessA", jid: "111@g.us", subject: "Alpha" },
{ id: "2", appId: "app1", sessionId: "sessB", jid: "222@g.us", subject: "Beta" },
{ id: "3", appId: "app2", sessionId: "sessC", jid: "333@g.us", subject: "Gamma" },
];
it("filters by appId only", () => {
expect(listGroups(rows, "app1")).toHaveLength(2);
});
it("filters by appId + sessionId", () => {
const result = listGroups(rows, "app1", "sessA");
expect(result).toHaveLength(1);
expect(result[0].subject).toBe("Alpha");
});
it("wrong appId returns empty", () => {
expect(listGroups(rows, "app999")).toHaveLength(0);
});
it("wrong sessionId returns empty", () => {
expect(listGroups(rows, "app1", "sessZ")).toHaveLength(0);
});
it("different app's groups not included", () => {
const result = listGroups(rows, "app1");
expect(result.every((r) => r.appId === "app1")).toBe(true);
});
});
// ─── Subject/description normalization ───────────────────────────────────────
function normalizeSubject(raw: string | undefined | null): string | null {
if (!raw?.trim()) return null;
return raw.trim();
}
describe("group subject normalization", () => {
it("undefined → null", () => {
expect(normalizeSubject(undefined)).toBeNull();
});
it("null → null", () => {
expect(normalizeSubject(null)).toBeNull();
});
it("empty string → null", () => {
expect(normalizeSubject("")).toBeNull();
});
it("whitespace-only → null", () => {
expect(normalizeSubject(" ")).toBeNull();
});
it("valid string trimmed", () => {
expect(normalizeSubject(" Dev Team ")).toBe("Dev Team");
});
it("normal string unchanged", () => {
expect(normalizeSubject("Marketing")).toBe("Marketing");
});
});
// ─── Own JID matching ─────────────────────────────────────────────────────────
function jidUser(jid: string): string {
return jid.split(":")[0].split("@")[0];
}
function isOwnJid(participant: string, ownJid: string): boolean {
return participant === ownJid || jidUser(participant) === jidUser(ownJid);
}
function participantsIncludeOwn(participants: string[], ownJid: string): boolean {
return participants.some((p) => isOwnJid(p, ownJid));
}
describe("own JID matching", () => {
const OWN = "6281234567890@s.whatsapp.net";
it("exact match", () => {
expect(isOwnJid(OWN, OWN)).toBe(true);
});
it("LID suffix variant matches (e.g. 628...@lid vs 628...@s.whatsapp.net)", () => {
// Baileys may use :0 device suffix
expect(isOwnJid("6281234567890:0@s.whatsapp.net", "6281234567890@s.whatsapp.net")).toBe(true);
});
it("different number does not match", () => {
expect(isOwnJid("6289999999999@s.whatsapp.net", OWN)).toBe(false);
});
it("empty participants → not own", () => {
expect(participantsIncludeOwn([], OWN)).toBe(false);
});
it("list with own JID → true", () => {
expect(participantsIncludeOwn([OWN, "6289@s.whatsapp.net"], OWN)).toBe(true);
});
it("list without own JID → false", () => {
expect(participantsIncludeOwn(["6289@s.whatsapp.net"], OWN)).toBe(false);
});
});
// ─── Webhook event routing ────────────────────────────────────────────────────
type GroupWebhookEvent =
| "group.joined"
| "group.left"
| "group.updated"
| "group.participants_added"
| "group.participants_removed";
interface GroupParticipantsUpdate {
id: string;
participants: string[];
action: "add" | "remove" | "promote" | "demote";
}
function routeParticipantsEvent(
update: GroupParticipantsUpdate,
ownJid: string,
): GroupWebhookEvent | null {
const ownInvolved = participantsIncludeOwn(update.participants, ownJid);
if (update.action === "add") {
return ownInvolved ? "group.joined" : "group.participants_added";
}
if (update.action === "remove") {
return ownInvolved ? "group.left" : "group.participants_removed";
}
return null; // promote/demote: no group webhook
}
describe("webhook event routing (group-participants.update)", () => {
const OWN = "6281234567890@s.whatsapp.net";
const GID = "120363429229698693@g.us";
const OTHER = "6289999999999@s.whatsapp.net";
it("add own JID → group.joined", () => {
expect(routeParticipantsEvent({ id: GID, participants: [OWN], action: "add" }, OWN))
.toBe("group.joined");
});
it("remove own JID → group.left", () => {
expect(routeParticipantsEvent({ id: GID, participants: [OWN], action: "remove" }, OWN))
.toBe("group.left");
});
it("add other JID → group.participants_added", () => {
expect(routeParticipantsEvent({ id: GID, participants: [OTHER], action: "add" }, OWN))
.toBe("group.participants_added");
});
it("remove other JID → group.participants_removed", () => {
expect(routeParticipantsEvent({ id: GID, participants: [OTHER], action: "remove" }, OWN))
.toBe("group.participants_removed");
});
it("promote → null (no group webhook)", () => {
expect(routeParticipantsEvent({ id: GID, participants: [OTHER], action: "promote" }, OWN))
.toBeNull();
});
it("demote → null (no group webhook)", () => {
expect(routeParticipantsEvent({ id: GID, participants: [OTHER], action: "demote" }, OWN))
.toBeNull();
});
it("multiple participants, own included → group.joined", () => {
expect(routeParticipantsEvent({ id: GID, participants: [OTHER, OWN], action: "add" }, OWN))
.toBe("group.joined");
});
});
// ─── Webhook payload shapes ───────────────────────────────────────────────────
interface GroupMeta {
jid: string;
subject: string | null;
description: string | null;
participantCount: number | null;
}
function buildGroupJoinedPayload(sessionId: string, meta: GroupMeta) {
return {
sessionId,
jid: meta.jid,
subject: meta.subject,
description: meta.description,
participantCount: meta.participantCount,
};
}
function buildGroupLeftPayload(sessionId: string, jid: string, participants: string[]) {
return { sessionId, jid, participants };
}
function buildGroupUpdatedPayload(sessionId: string, jid: string, subject?: string | null, description?: string | null) {
return { sessionId, jid, subject: subject ?? null, description: description ?? null };
}
function buildParticipantsPayload(sessionId: string, jid: string, participants: string[]) {
return { sessionId, jid, participants };
}
describe("group.joined payload", () => {
it("includes all metadata fields", () => {
const p = buildGroupJoinedPayload("sess1", {
jid: "123@g.us",
subject: "Dev",
description: "Chat",
participantCount: 5,
});
expect(p).toEqual({
sessionId: "sess1",
jid: "123@g.us",
subject: "Dev",
description: "Chat",
participantCount: 5,
});
});
it("null metadata fields preserved", () => {
const p = buildGroupJoinedPayload("sess1", {
jid: "123@g.us",
subject: null,
description: null,
participantCount: null,
});
expect(p.subject).toBeNull();
expect(p.description).toBeNull();
expect(p.participantCount).toBeNull();
});
});
describe("group.left payload", () => {
it("includes sessionId, jid, participants", () => {
const p = buildGroupLeftPayload("sess1", "123@g.us", ["628@s.whatsapp.net"]);
expect(p).toEqual({
sessionId: "sess1",
jid: "123@g.us",
participants: ["628@s.whatsapp.net"],
});
});
it("does NOT include subject or description", () => {
const p = buildGroupLeftPayload("s", "j", ["x"]);
expect(p).not.toHaveProperty("subject");
expect(p).not.toHaveProperty("description");
});
});
describe("group.updated payload", () => {
it("includes changed subject", () => {
const p = buildGroupUpdatedPayload("sess1", "123@g.us", "New Name", null);
expect(p.subject).toBe("New Name");
expect(p.description).toBeNull();
});
it("both null when no metadata in update", () => {
const p = buildGroupUpdatedPayload("sess1", "123@g.us");
expect(p.subject).toBeNull();
expect(p.description).toBeNull();
});
});
describe("group.participants_added / group.participants_removed payload", () => {
it("shape: sessionId, jid, participants array", () => {
const p = buildParticipantsPayload("sess1", "123@g.us", ["a@s.whatsapp.net", "b@s.whatsapp.net"]);
expect(p.sessionId).toBe("sess1");
expect(p.jid).toBe("123@g.us");
expect(p.participants).toHaveLength(2);
});
it("does NOT include subject, description, participantCount", () => {
const p = buildParticipantsPayload("s", "j", ["x"]);
expect(p).not.toHaveProperty("subject");
expect(p).not.toHaveProperty("description");
expect(p).not.toHaveProperty("participantCount");
});
it("empty participants array is valid", () => {
const p = buildParticipantsPayload("s", "j", []);
expect(p.participants).toHaveLength(0);
});
});
// ─── All 15 event types recognized ───────────────────────────────────────────
describe("ALL_EVENTS completeness", () => {
const ALL_EVENTS = [
"session.status",
"session.deleted",
"message.received",
"message.sent",
"message.delivered",
"message.read",
"message.failed",
"presence.update",
"contact.created",
"contact.updated",
"group.joined",
"group.left",
"group.updated",
"group.participants_added",
"group.participants_removed",
];
it("has 15 event types", () => {
expect(ALL_EVENTS).toHaveLength(15);
});
it("no duplicates", () => {
expect(new Set(ALL_EVENTS).size).toBe(ALL_EVENTS.length);
});
it("all group events present", () => {
const groupEvents = ALL_EVENTS.filter((e) => e.startsWith("group."));
expect(groupEvents).toHaveLength(5);
});
it("group events follow noun.verb pattern", () => {
const groupEvents = ALL_EVENTS.filter((e) => e.startsWith("group."));
for (const e of groupEvents) {
expect(e).toMatch(/^group\.[a-z_]+$/);
}
});
});