Skip to content

Commit aa56330

Browse files
committed
Add shadcn GitHub registry for Cossistant support
1 parent fca8b0e commit aa56330

56 files changed

Lines changed: 2531 additions & 90 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

apps/api/src/db/queries/contact-identify.test.ts

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import { describe, expect, it, mock } from "bun:test";
22
import { contact } from "../schema";
3-
import { identifyContact, upsertContactByExternalId } from "./contact";
3+
import {
4+
deleteContactsForWebsite,
5+
identifyContact,
6+
upsertContactByExternalId,
7+
} from "./contact";
48

59
describe("upsertContactByExternalId", () => {
610
it("returns created when insert succeeds", async () => {
@@ -169,6 +173,57 @@ describe("upsertContactByExternalId", () => {
169173
});
170174
});
171175

176+
describe("deleteContactsForWebsite", () => {
177+
it("soft deletes active contacts scoped to website and organization", async () => {
178+
const returningMock = mock((async () => [
179+
{ id: "contact-1" },
180+
{ id: "contact-2" },
181+
]) as (fields: unknown) => Promise<unknown[]>);
182+
const updateWhereMock = mock((() => ({
183+
returning: returningMock,
184+
})) as (where: unknown) => {
185+
returning: (fields: unknown) => Promise<unknown[]>;
186+
});
187+
const updateSetMock = mock((() => ({
188+
where: updateWhereMock,
189+
})) as (set: unknown) => {
190+
where: (where: unknown) => {
191+
returning: (fields: unknown) => Promise<unknown[]>;
192+
};
193+
});
194+
const updateMock = mock((() => ({
195+
set: updateSetMock,
196+
})) as (table: unknown) => {
197+
set: (set: unknown) => {
198+
where: (where: unknown) => {
199+
returning: (fields: unknown) => Promise<unknown[]>;
200+
};
201+
};
202+
});
203+
204+
const db = {
205+
update: updateMock,
206+
};
207+
208+
const deletedCount = await deleteContactsForWebsite(db as never, {
209+
websiteId: "site-1",
210+
organizationId: "org-1",
211+
});
212+
213+
expect(deletedCount).toBe(2);
214+
expect(updateMock).toHaveBeenCalledWith(contact);
215+
expect(updateSetMock).toHaveBeenCalledTimes(1);
216+
const updateArg = updateSetMock.mock.calls[0]?.[0] as {
217+
deletedAt: string;
218+
updatedAt: string;
219+
};
220+
expect(updateArg.deletedAt).toEqual(expect.any(String));
221+
expect(updateArg.updatedAt).toEqual(expect.any(String));
222+
expect(updateWhereMock).toHaveBeenCalledTimes(1);
223+
expect(returningMock).toHaveBeenCalledWith({ id: contact.id });
224+
});
225+
});
226+
172227
describe("identifyContact", () => {
173228
it("keeps email-only update path and merges metadata in-memory", async () => {
174229
const existingContact = {

apps/api/src/db/queries/contact.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -373,6 +373,36 @@ export async function deleteContact(
373373
return deleted ?? null;
374374
}
375375

376+
/**
377+
* Soft delete all active contacts scoped to a website and organization.
378+
*/
379+
export async function deleteContactsForWebsite(
380+
db: Database,
381+
params: {
382+
websiteId: string;
383+
organizationId: string;
384+
}
385+
): Promise<number> {
386+
const now = new Date().toISOString();
387+
388+
const deleted = await db
389+
.update(contact)
390+
.set({
391+
deletedAt: now,
392+
updatedAt: now,
393+
})
394+
.where(
395+
and(
396+
eq(contact.websiteId, params.websiteId),
397+
eq(contact.organizationId, params.organizationId),
398+
isNull(contact.deletedAt)
399+
)
400+
)
401+
.returning({ id: contact.id });
402+
403+
return deleted.length;
404+
}
405+
376406
/**
377407
* Link a visitor to a contact
378408
*/
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import { beforeEach, describe, expect, it, mock } from "bun:test";
2+
import { contact } from "@api/db/schema";
3+
4+
const getWebsiteBySlugWithAccessMock = mock(
5+
(async () => null) as (...args: unknown[]) => Promise<unknown>
6+
);
7+
8+
mock.module("@api/db/queries/website", () => ({
9+
getWebsiteBySlugWithAccess: getWebsiteBySlugWithAccessMock,
10+
}));
11+
12+
const modulePromise = Promise.all([import("../init"), import("./contact")]);
13+
14+
const website = {
15+
id: "site-1",
16+
organizationId: "org-1",
17+
};
18+
const contactId = "01ARZ3NDEKTSV4RRFFQ69G5FAV";
19+
20+
function createDb(returningResults: unknown[][]) {
21+
let updateIndex = 0;
22+
const returningMock = mock((async (_fields?: unknown) => {
23+
const result = returningResults[updateIndex] ?? [];
24+
updateIndex += 1;
25+
return result;
26+
}) as (_fields?: unknown) => Promise<unknown[]>);
27+
const updateWhereMock = mock((() => ({
28+
returning: returningMock,
29+
})) as (_where: unknown) => {
30+
returning: (_fields?: unknown) => Promise<unknown[]>;
31+
});
32+
const updateSetMock = mock((() => ({
33+
where: updateWhereMock,
34+
})) as (_set: unknown) => {
35+
where: (_where: unknown) => {
36+
returning: (_fields?: unknown) => Promise<unknown[]>;
37+
};
38+
});
39+
const updateMock = mock((() => ({
40+
set: updateSetMock,
41+
})) as (_table: unknown) => {
42+
set: (_set: unknown) => {
43+
where: (_where: unknown) => {
44+
returning: (_fields?: unknown) => Promise<unknown[]>;
45+
};
46+
};
47+
});
48+
49+
return {
50+
db: {
51+
update: updateMock,
52+
},
53+
returningMock,
54+
updateMock,
55+
updateSetMock,
56+
updateWhereMock,
57+
};
58+
}
59+
60+
async function createCaller(db: unknown) {
61+
const [{ createCallerFactory }, { contactRouter }] = await modulePromise;
62+
const createCallerFactoryForRouter = createCallerFactory(contactRouter);
63+
64+
return createCallerFactoryForRouter({
65+
db: db as never,
66+
user: {
67+
id: "user-1",
68+
name: "User One",
69+
email: "user@example.com",
70+
} as never,
71+
session: { id: "session-1" } as never,
72+
geo: {} as never,
73+
headers: new Headers(),
74+
});
75+
}
76+
77+
describe("contact router deletion mutations", () => {
78+
beforeEach(() => {
79+
getWebsiteBySlugWithAccessMock.mockReset();
80+
81+
getWebsiteBySlugWithAccessMock.mockResolvedValue(website);
82+
});
83+
84+
it("soft deletes a single contact through the dashboard router", async () => {
85+
const harness = createDb([[{ id: contactId }]]);
86+
const caller = await createCaller(harness.db);
87+
88+
const result = await caller.delete({
89+
websiteSlug: "acme",
90+
contactId,
91+
});
92+
93+
expect(result).toEqual({ id: contactId });
94+
expect(getWebsiteBySlugWithAccessMock).toHaveBeenCalledWith(harness.db, {
95+
userId: "user-1",
96+
websiteSlug: "acme",
97+
});
98+
expect(harness.updateMock).toHaveBeenCalledWith(contact);
99+
expect(harness.updateSetMock).toHaveBeenCalledTimes(1);
100+
expect(harness.updateWhereMock).toHaveBeenCalledTimes(1);
101+
expect(harness.returningMock).toHaveBeenCalledTimes(1);
102+
});
103+
104+
it("rejects single contact deletion when the contact is not found", async () => {
105+
const harness = createDb([[]]);
106+
const caller = await createCaller(harness.db);
107+
108+
await expect(
109+
caller.delete({
110+
websiteSlug: "acme",
111+
contactId,
112+
})
113+
).rejects.toMatchObject({
114+
code: "NOT_FOUND",
115+
message: "Contact not found",
116+
});
117+
});
118+
119+
it("rejects bulk deletion when website access fails", async () => {
120+
getWebsiteBySlugWithAccessMock.mockResolvedValueOnce(null);
121+
const harness = createDb([]);
122+
const caller = await createCaller(harness.db);
123+
124+
await expect(
125+
caller.deleteAll({ websiteSlug: "acme" })
126+
).rejects.toMatchObject({
127+
code: "NOT_FOUND",
128+
message: "Website not found or access denied",
129+
});
130+
expect(harness.updateMock).not.toHaveBeenCalled();
131+
});
132+
133+
it("soft deletes all contacts scoped to the website and organization", async () => {
134+
const harness = createDb([
135+
[{ id: "contact-1" }, { id: "contact-2" }, { id: "contact-3" }],
136+
]);
137+
const caller = await createCaller(harness.db);
138+
139+
const result = await caller.deleteAll({ websiteSlug: "acme" });
140+
141+
expect(result).toEqual({ deletedCount: 3 });
142+
expect(harness.updateMock).toHaveBeenCalledWith(contact);
143+
expect(harness.updateSetMock).toHaveBeenCalledTimes(1);
144+
expect(harness.updateWhereMock).toHaveBeenCalledTimes(1);
145+
expect(harness.returningMock).toHaveBeenCalledWith({ id: contact.id });
146+
});
147+
});

apps/api/src/trpc/routers/contact.ts

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,17 @@
1-
import { getContactWithVisitors, listContacts } from "@api/db/queries/contact";
1+
import {
2+
deleteContact,
3+
deleteContactsForWebsite,
4+
getContactWithVisitors,
5+
listContacts,
6+
} from "@api/db/queries/contact";
27
import { getWebsiteBySlugWithAccess } from "@api/db/queries/website";
38
import {
49
contactDetailResponseSchema,
510
contactListVisitorStatusSchema,
11+
deleteAllContactsRequestSchema,
12+
deleteAllContactsResponseSchema,
13+
deleteContactRequestSchema,
14+
deleteContactResponseSchema,
615
listContactsResponseSchema,
716
} from "@cossistant/types";
817
import { TRPCError } from "@trpc/server";
@@ -97,4 +106,57 @@ export const contactRouter = createTRPCRouter({
97106

98107
return record;
99108
}),
109+
delete: protectedProcedure
110+
.input(deleteContactRequestSchema)
111+
.output(deleteContactResponseSchema)
112+
.mutation(async ({ ctx: { db, user }, input }) => {
113+
const websiteData = await getWebsiteBySlugWithAccess(db, {
114+
userId: user.id,
115+
websiteSlug: input.websiteSlug,
116+
});
117+
118+
if (!websiteData) {
119+
throw new TRPCError({
120+
code: "NOT_FOUND",
121+
message: "Website not found or access denied",
122+
});
123+
}
124+
125+
const deleted = await deleteContact(db, {
126+
contactId: input.contactId,
127+
websiteId: websiteData.id,
128+
});
129+
130+
if (!deleted) {
131+
throw new TRPCError({
132+
code: "NOT_FOUND",
133+
message: "Contact not found",
134+
});
135+
}
136+
137+
return { id: deleted.id };
138+
}),
139+
deleteAll: protectedProcedure
140+
.input(deleteAllContactsRequestSchema)
141+
.output(deleteAllContactsResponseSchema)
142+
.mutation(async ({ ctx: { db, user }, input }) => {
143+
const websiteData = await getWebsiteBySlugWithAccess(db, {
144+
userId: user.id,
145+
websiteSlug: input.websiteSlug,
146+
});
147+
148+
if (!websiteData) {
149+
throw new TRPCError({
150+
code: "NOT_FOUND",
151+
message: "Website not found or access denied",
152+
});
153+
}
154+
155+
const deletedCount = await deleteContactsForWebsite(db, {
156+
websiteId: websiteData.id,
157+
organizationId: websiteData.organizationId,
158+
});
159+
160+
return { deletedCount };
161+
}),
100162
});

apps/web/content/docs/(root)/index.mdx

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,25 @@ title: Cossistant documentation
33
description: Cossistant is an open source, developer-first AI/human support infrastructure. Works with your favorite frameworks and AI models.
44
---
55

6+
## Install with the shadcn registry
7+
8+
Add the Next.js `<Support />` starter with one command:
9+
10+
```bash
11+
bunx --bun shadcn@latest add cossistantcom/cossistant/support
12+
```
13+
14+
For React and Vite apps, use the React registry item:
15+
16+
```bash
17+
bunx --bun shadcn@latest add cossistantcom/cossistant/support-react
18+
```
19+
20+
The registry installs the Cossistant provider, support trigger, dependencies, CSS import, and API key placeholder into your configured shadcn component directory.
21+
622
## Quickstart with your framework
723

8-
Start by selecting your framework of choice, then follow the instructions to install the dependencies and structure your app. Cossistant currently ships quickstarts for Next.js and React.
24+
Start by selecting your framework of choice, then follow the instructions to finish mounting the provider, adding your API key, and rendering `<Support />`. Cossistant currently ships quickstarts for Next.js and React.
925

1026
<div className="mt-8 grid gap-4 sm:grid-cols-2 sm:gap-6">
1127
<LinkedCard href="/docs/quickstart">

0 commit comments

Comments
 (0)