WhatsApp/Signal/Formstack/admin updates

This commit is contained in:
Darren Clarke 2025-11-21 14:55:28 +01:00
parent bcecf61a46
commit d0cc5a21de
451 changed files with 16139 additions and 39623 deletions

View file

@ -34,6 +34,8 @@ export const Detail: FC<DetailProps> = ({ service, row }) => {
const { almostBlack } = colors;
const { bodyLarge } = typography;
const [showDeleteConfirmation, setShowDeleteConfirmation] = useState(false);
const [showRelinkConfirmation, setShowRelinkConfirmation] = useState(false);
const [isRelinking, setIsRelinking] = useState(false);
const continueDeleteAction = async () => {
await deleteAction?.(id);
@ -41,6 +43,23 @@ export const Detail: FC<DetailProps> = ({ service, row }) => {
router.push(`${getBasePath()}${entity}`);
};
const continueRelinkAction = async () => {
setIsRelinking(true);
try {
const response = await fetch(`/link/api/${entity}/bots/${token}/relink`, {
method: "POST",
});
if (response.ok) {
setShowRelinkConfirmation(false);
router.refresh();
}
} catch (error) {
console.error("Relink failed:", error);
} finally {
setIsRelinking(false);
}
};
return (
<>
<Dialog
@ -57,6 +76,15 @@ export const Detail: FC<DetailProps> = ({ service, row }) => {
onClick={() => setShowDeleteConfirmation(true)}
/>
</Grid>
{service === "whatsapp" && (
<Grid item>
<Button
text="Relink"
kind="secondary"
onClick={() => setShowRelinkConfirmation(true)}
/>
</Grid>
)}
<Grid item>
<Button
text="Edit"
@ -129,6 +157,35 @@ export const Detail: FC<DetailProps> = ({ service, row }) => {
Are you sure you want to delete this record?
</Box>
</Dialog>
<Dialog
open={showRelinkConfirmation}
size="xs"
title="Relink WhatsApp Connection?"
buttons={
<Grid container justifyContent="space-between">
<Grid item>
<Button
text="Cancel"
kind="secondary"
onClick={() => setShowRelinkConfirmation(false)}
disabled={isRelinking}
/>
</Grid>
<Grid item>
<Button
text={isRelinking ? "Relinking..." : "Relink"}
kind="primary"
onClick={continueRelinkAction}
disabled={isRelinking}
/>
</Grid>
</Grid>
}
>
<Box sx={{ ...bodyLarge, color: almostBlack }}>
This will disconnect the current WhatsApp link and generate a new QR code. You will need to scan the new QR code to reconnect. Continue?
</Box>
</Dialog>
</>
);
};

View file

@ -1,4 +1,5 @@
import { FC, useEffect, useState } from "react";
// @ts-ignore - react-qr-code doesn't have React 19 compatible types yet
import QRCodeInternal from "react-qr-code";
import { Box } from "@mui/material";
import { colors } from "../styles/theme";
@ -28,22 +29,30 @@ export const QRCode: FC<QRCodeProps> = ({
useEffect(() => {
if (!verified && getValue && refreshInterval) {
const interval = setInterval(async () => {
// Fetch immediately on mount
const fetchQR = async () => {
const { qr, kind } = await getValue(token);
console.log({ kind });
setValue(qr);
setKind(kind);
}, refreshInterval * 1000);
};
fetchQR();
// Then set up interval for refreshes
const interval = setInterval(fetchQR, refreshInterval * 1000);
return () => clearInterval(interval);
}
}, [getValue, refreshInterval]);
}, [getValue, refreshInterval, token, verified]);
return !verified ? (
<Box sx={{ backgroundColor: white, m: 2 }}>
{kind === "data" ? (
<QRCodeInternal value={value} />
{value ? (
kind === "data" ? (
<QRCodeInternal value={value} />
) : (
<img src={value} alt={name} />
)
) : (
<img src={value} alt={name} />
<Box>Loading QR code...</Box>
)}
<Box>{helperText}</Box>
</Box>

View file

@ -3,18 +3,19 @@ type ServiceLayoutProps = {
detail: any;
edit: any;
create: any;
params: {
params: Promise<{
segment: string[];
};
}>;
};
export const ServiceLayout = ({
export const ServiceLayout = async ({
children,
detail,
edit,
create,
params: { segment },
params,
}: ServiceLayoutProps) => {
const { segment } = await params;
const length = segment?.length ?? 0;
const isCreate = length === 2 && segment[1] === "create";
const isEdit = length === 3 && segment[2] === "edit";

View file

@ -1,7 +1,10 @@
import { ServiceConfig } from "../lib/service";
const getQRCode = async (token: string): Promise<Record<string, string>> => {
const url = `/link/api/signal/bots/${token}`;
const basePath = window?.location?.pathname?.startsWith("/link")
? "/link"
: "";
const url = `${basePath}/api/signal/bots/${token}`;
const result = await fetch(url, { cache: "no-store" });
const { qr } = await result.json();

View file

@ -2,11 +2,28 @@ import { ServiceConfig } from "../lib/service";
// import { generateSelectOneAction } from "../lib/actions";
const getQRCode = async (token: string) => {
const url = `/link/api/whatsapp/bots/${token}`;
const result = await fetch(url, { cache: "no-store" });
const { qr } = await result.json();
try {
const url = `/link/api/whatsapp/bots/${token}`;
const result = await fetch(url, { cache: "no-store" });
return { qr, kind: "data" };
if (!result.ok) {
console.error(`Failed to fetch QR code: ${result.status} ${result.statusText}`);
return { qr: "", kind: "data" };
}
const data = await result.json();
const { qr } = data;
if (!qr) {
console.error("No QR code in response");
return { qr: "", kind: "data" };
}
return { qr, kind: "data" };
} catch (error) {
console.error("Error fetching QR code:", error);
return { qr: "", kind: "data" };
}
};
export const whatsappConfig: ServiceConfig = {

View file

@ -10,4 +10,5 @@ export {
sendMessage,
receiveMessage,
handleWebhook,
relinkBot,
} from "./lib/routing";

View file

@ -5,19 +5,26 @@ import { getService } from "./utils";
export const getBot = async (
_req: NextRequest,
params: ServiceParams,
): Promise<NextResponse> => getService(params)?.getBot(params);
): Promise<NextResponse> => (await getService(params))?.getBot(params);
export const sendMessage = async (
req: NextRequest,
params: ServiceParams,
): Promise<NextResponse> => getService(params)?.sendMessage(req, params);
): Promise<NextResponse> =>
(await getService(params))?.sendMessage(req, params);
export const receiveMessage = async (
req: NextRequest,
params: ServiceParams,
): Promise<NextResponse> => getService(params)?.receiveMessage(req, params);
): Promise<NextResponse> =>
(await getService(params))?.receiveMessage(req, params);
export const handleWebhook = async (
req: NextRequest,
params: ServiceParams,
): Promise<NextResponse> => getService(params)?.handleWebhook(req);
): Promise<NextResponse> => (await getService(params))?.handleWebhook(req);
export const relinkBot = async (
_req: NextRequest,
params: ServiceParams,
): Promise<NextResponse> => (await getService(params))?.relink(params);

View file

@ -51,16 +51,15 @@ export type ServiceConfig = {
};
export type ServiceParams = {
params: {
params: Promise<{
service: string;
token?: string;
};
}>;
};
export class Service {
async getBot({
params: { service, token },
}: ServiceParams): Promise<NextResponse> {
async getBot({ params }: ServiceParams): Promise<NextResponse> {
const { service, token } = await params;
const table = getServiceTable(service);
const row = await db
.selectFrom(table)
@ -71,16 +70,15 @@ export class Service {
return NextResponse.json(row);
}
async registerBot({
params: { service, token },
}: ServiceParams): Promise<NextResponse> {
async registerBot({ params: _params }: ServiceParams): Promise<NextResponse> {
return NextResponse.error() as any;
}
async sendMessage(
req: NextRequest,
{ params: { service, token } }: ServiceParams,
{ params }: ServiceParams,
): Promise<NextResponse> {
const { service, token } = await params;
const table = getServiceTable(service);
const row = await db
.selectFrom(table)
@ -103,14 +101,14 @@ export class Service {
},
};
console.log(response);
return NextResponse.json(response);
}
async receiveMessage(
req: NextRequest,
{ params: { service, token } }: ServiceParams,
{ params }: ServiceParams,
): Promise<NextResponse> {
const { service, token } = await params;
const json = await req.json();
const worker = await getWorkerUtils();
await worker.addJob(`${service}/receive-${service}-message`, {
@ -124,4 +122,8 @@ export class Service {
async handleWebhook(_req: NextRequest): Promise<NextResponse> {
return NextResponse.error() as any;
}
async relink({ params: _params }: ServiceParams): Promise<NextResponse> {
return NextResponse.error() as any;
}
}

View file

@ -11,7 +11,8 @@ const fetchNoCache = async (url: string, options = {}) => {
};
export class Signal extends Service {
async getBot({ params: { token } }: ServiceParams) {
async getBot({ params }: ServiceParams) {
const { token } = await params;
const row = await db
.selectFrom("SignalBot")
.selectAll()

View file

@ -3,7 +3,10 @@ import { Facebook } from "./facebook";
import { Signal } from "./signal";
import { Whatsapp } from "./whatsapp";
export const getService = ({ params: { service } }: ServiceParams): Service => {
export const getService = async ({
params,
}: ServiceParams): Promise<Service> => {
const { service } = await params;
if (service === "facebook") {
return new Facebook();
} else if (service === "signal") {

View file

@ -4,7 +4,8 @@ import { revalidatePath } from "next/cache";
import { Service, ServiceParams } from "./service";
export class Whatsapp extends Service {
async getBot({ params: { token } }: ServiceParams) {
async getBot({ params }: ServiceParams) {
const { token } = await params;
const row = await db
.selectFrom("WhatsappBot")
.selectAll()
@ -30,4 +31,30 @@ export class Whatsapp extends Service {
return NextResponse.json(json);
}
async relink({ params }: ServiceParams) {
const { token } = await params;
const row = await db
.selectFrom("WhatsappBot")
.selectAll()
.where("token", "=", token as string)
.executeTakeFirstOrThrow();
const id = row.id;
// Step 1: Call unverify to remove the bot directory and disconnect
const unverifyUrl = `${process.env.BRIDGE_WHATSAPP_URL}/api/bots/${id}/unverify`;
await fetch(unverifyUrl, { method: "POST" });
// Step 2: Reset verified flag in database
await db
.updateTable("WhatsappBot")
.set({ verified: false })
.where("id", "=", id)
.execute();
// Step 3: Revalidate the path to refresh the UI
revalidatePath(`/whatsapp/${id}`);
return NextResponse.json({ success: true, message: "WhatsApp connection reset. Please scan the new QR code." });
}
}

View file

@ -1,25 +1,25 @@
{
"name": "@link-stack/bridge-ui",
"version": "2.2.0",
"version": "3.3.0",
"scripts": {
"build": "tsc -p tsconfig.json"
},
"dependencies": {
"@link-stack/bridge-common": "^2.2.0",
"@link-stack/signal-api": "*",
"@link-stack/ui": "^2.2.0",
"@mui/material": "^5",
"@mui/x-data-grid-pro": "^7.18.0",
"kysely": "0.26.1",
"next": "^14.2.25",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-qr-code": "^2.0.15"
"@link-stack/bridge-common": "workspace:*",
"@link-stack/signal-api": "workspace:*",
"@link-stack/ui": "workspace:*",
"@mui/material": "^6",
"@mui/x-data-grid-pro": "^7",
"kysely": "0.27.5",
"next": "15.5.4",
"react": "19.2.0",
"react-dom": "19.2.0",
"react-qr-code": "^2.0.18"
},
"devDependencies": {
"@types/node": "^22.7.3",
"@types/react": "^18",
"@types/react-dom": "^18",
"typescript": "5.6.2"
"@types/node": "^24.7.0",
"@types/react": "19.2.2",
"@types/react-dom": "^19.2.1",
"typescript": "5.9.3"
}
}

View file

@ -38,8 +38,6 @@ export const colors: any = {
helpYellow: "#fff4d5",
dwcDarkBlue: "#191847",
hazyMint: "#ecf7f8",
leafcutterElectricBlue: "#4d6aff",
leafcutterLightBlue: "#fafbfd",
waterbearElectricPurple: "#332c83",
waterbearLightSmokePurple: "#eff3f8",
bumpedPurple: "#212058",

View file

@ -1,26 +1,32 @@
{
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"forceConsistentCasingInFileNames": true,
"noEmit": false,
"outDir": "./dist",
"declaration": true,
"declarationMap": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"baseUrl": ".",
"paths": {
"@/*": ["./*", "../../node_modules/*"]
},
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
]
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
"include": ["**.d.ts", "**/*.ts", "**/*.tsx", "**/*.png, **/*.svg"],
"exclude": ["node_modules", "babel__core", "dist"]
}