Refactoring 2

This commit is contained in:
Darren Clarke 2024-04-30 11:39:16 +02:00
parent dd14dfe72e
commit e4b78ceec2
76 changed files with 870 additions and 734 deletions

View file

@ -1,4 +1,4 @@
import { Create } from "./_components/Create"; import { Create } from "bridge-ui";
type PageProps = { type PageProps = {
params: { segment: string[] }; params: { segment: string[] };

View file

@ -1,45 +0,0 @@
"use client";
import { FC } from "react";
import { Grid } from "@mui/material";
import { DisplayTextField } from "ui";
import { Selectable } from "kysely";
import { Database } from "@/app/_lib/database";
import { Detail as InternalDetail } from "@/app/_components/Detail";
import { generateDeleteAction } from "@/app/_lib/actions";
import { serviceConfig } from "@/app/_config/config";
type DetailProps = {
service: string;
row: Selectable<keyof Database>;
};
export const Detail: FC<DetailProps> = ({ service, row }) => {
const {
[service]: { entity, table, displayName, displayFields: fields },
} = serviceConfig;
const deleteAction = generateDeleteAction({ entity, table });
return (
<InternalDetail
title={`${displayName}: ${row.name}`}
entity={entity}
id={row.id as string}
deleteAction={deleteAction}
>
<Grid container direction="row" rowSpacing={3} columnSpacing={2}>
{fields.map((field) => (
<Grid item xs={field.size ?? 6} key={field.name}>
<DisplayTextField
name={field.name}
label={field.label}
lines={field.lines ?? 1}
value={row[field.name] as string}
copyable={field.copyable ?? false}
/>
</Grid>
))}
</Grid>
</InternalDetail>
);
};

View file

@ -1,6 +1,5 @@
import { db } from "@/app/_lib/database"; import { db } from "bridge-common";
import { serviceConfig } from "@/app/_config/config"; import { serviceConfig, Detail } from "bridge-ui";
import { Detail } from "./_components/Detail";
type Props = { type Props = {
params: { segment: string[] }; params: { segment: string[] };

View file

@ -1,60 +0,0 @@
"use client";
import { FC } from "react";
import { useFormState } from "react-dom";
import { Grid } from "@mui/material";
import { TextField } from "ui";
import { Selectable } from "kysely";
import { Database } from "@/app/_lib/database";
import { Edit as InternalEdit } from "@/app/_components/Edit";
import { generateUpdateAction } from "@/app/_lib/actions";
import { serviceConfig } from "@/app/_config/config";
type EditProps = {
service: string;
row: Selectable<keyof Database>;
};
export const Edit: FC<EditProps> = ({ service, row }) => {
const {
[service]: { entity, table, displayName, updateFields: fields },
} = serviceConfig;
const updateFieldNames = fields.map((val) => val.name);
const updateAction = generateUpdateAction({ entity, table, fields });
const updateValues = Object.fromEntries(
Object.entries(row).filter(([key]) => updateFieldNames.includes(key)),
);
updateValues.id = row.id;
const initialState = {
message: null,
errors: {},
values: updateValues,
};
const [formState, formAction] = useFormState(updateAction, initialState);
return (
<InternalEdit
title={`Edit ${displayName}: ${row.name}`}
entity={entity}
formAction={formAction}
formState={formState}
>
<Grid container direction="row" rowSpacing={3} columnSpacing={2}>
{fields.map((field) => (
<Grid key={field.name} item xs={field.size ?? 6}>
<TextField
name={field.name}
label={field.label}
lines={field.lines ?? 1}
disabled={field.disabled ?? false}
refreshable={field.refreshable ?? false}
required={field.required ?? false}
formState={formState}
helperText={field.helperText}
/>
</Grid>
))}
</Grid>
</InternalEdit>
);
};

View file

@ -1,6 +1,5 @@
import { db } from "@/app/_lib/database"; import { db } from "bridge-common";
import { serviceConfig } from "@/app/_config/config"; import { serviceConfig, Edit } from "bridge-ui";
import { Edit } from "./_components/Edit";
type PageProps = { type PageProps = {
params: { segment: string[] }; params: { segment: string[] };

View file

@ -1,25 +0,0 @@
"use client";
import { FC } from "react";
import { List as InternalList } from "@/app/_components/List";
import type { Selectable } from "kysely";
import { Database } from "@/app/_lib/database";
import { serviceConfig } from "@/app/_config/config";
type ListProps = {
service: string;
rows: Selectable<keyof Database>[];
};
export const List: FC<ListProps> = ({ service, rows }) => {
const { displayName, entity, listColumns } = serviceConfig[service];
return (
<InternalList
title={`${displayName}s`}
entity={entity}
rows={rows}
columns={listColumns}
/>
);
};

View file

@ -1,32 +1,3 @@
type ServiceLayoutProps = { import { ServiceLayout } from "bridge-ui";
children: any;
detail: any;
edit: any;
create: any;
params: {
segment: string[];
};
};
export default function ServiceLayout({ export default ServiceLayout;
children,
detail,
edit,
create,
params: { segment },
}: ServiceLayoutProps) {
const length = segment?.length ?? 0;
const isCreate = length === 2 && segment[1] === "create";
const isEdit = length === 3 && segment[2] === "edit";
const id = length > 1 && !isCreate ? segment[1] : null;
const isDetail = length === 2 && !!id && !isCreate && !isEdit;
return (
<>
{children}
{isDetail && detail}
{isEdit && edit}
{isCreate && create}
</>
);
}

View file

@ -1,6 +1,5 @@
import { List } from "./_components/List"; import { db } from "bridge-common";
import { db } from "@/app/_lib/database"; import { serviceConfig, List } from "bridge-ui";
import { serviceConfig } from "@/app/_config/config";
type PageProps = { type PageProps = {
params: { params: {

View file

@ -1,55 +0,0 @@
"use client";
import { FC, useEffect } from "react";
import { Grid } from "@mui/material";
import { useRouter } from "next/navigation";
import { Button, Dialog } from "ui";
interface CreateProps {
title: string;
entity: string;
formAction: any;
formState: any;
children: any;
}
export const Create: FC<CreateProps> = ({
title,
entity,
formAction,
formState,
children,
}) => {
const router = useRouter();
useEffect(() => {
if (formState.success) {
router.push(`/${entity}/${formState.values.id}`);
}
}, [formState.success, router]);
return (
<Dialog
open
title={title}
formAction={formAction}
onClose={() => router.push(`/${entity}`)}
buttons={
<Grid container justifyContent="space-between">
<Grid item>
<Button
text="Cancel"
kind="secondary"
onClick={() => router.push(`/${entity}`)}
/>
</Grid>
<Grid item>
<Button text="Save" kind="primary" type="submit" />
</Grid>
</Grid>
}
>
{children}
</Dialog>
);
};

View file

@ -1,55 +0,0 @@
"use client";
import { FC, useEffect } from "react";
import { Grid } from "@mui/material";
import { useRouter } from "next/navigation";
import { Button, Dialog } from "ui";
interface EditProps {
title: string;
entity: string;
formAction: any;
formState: any;
children: any;
}
export const Edit: FC<EditProps> = ({
title,
entity,
formState,
formAction,
children,
}) => {
const router = useRouter();
useEffect(() => {
if (formState.success) {
router.push(`/${entity}`);
}
}, [formState.success, router, entity]);
return (
<Dialog
open
title={title}
formAction={formAction}
onClose={() => router.push(`/${entity}`)}
buttons={
<Grid container justifyContent="space-between">
<Grid item>
<Button
text="Cancel"
kind="secondary"
onClick={() => router.push(`/${entity}`)}
/>
</Grid>
<Grid item>
<Button text="Save" kind="primary" type="submit" />
</Grid>
</Grid>
}
>
{children}
</Dialog>
);
};

View file

@ -5,7 +5,7 @@ import { Grid } from "@mui/material";
import { CssBaseline } from "@mui/material"; import { CssBaseline } from "@mui/material";
import { SessionProvider } from "next-auth/react"; import { SessionProvider } from "next-auth/react";
import { css, Global } from "@emotion/react"; import { css, Global } from "@emotion/react";
import { fonts } from "@/app/_styles/theme"; import { fonts } from "ui";
import { Sidebar } from "./Sidebar"; import { Sidebar } from "./Sidebar";
export const InternalLayout: FC<PropsWithChildren> = ({ children }) => { export const InternalLayout: FC<PropsWithChildren> = ({ children }) => {

View file

@ -16,8 +16,8 @@ import {
} from "@mui/icons-material"; } from "@mui/icons-material";
import { signIn } from "next-auth/react"; import { signIn } from "next-auth/react";
import Image from "next/image"; import Image from "next/image";
import LinkLogo from "@/app/../public/link-logo-small.png"; import LinkLogo from "@/app/_images/link-logo-small.png";
import { colors } from "@/app/_styles/theme"; import { colors } from "ui";
import { useSearchParams } from "next/navigation"; import { useSearchParams } from "next/navigation";
type LoginProps = { type LoginProps = {

View file

@ -24,8 +24,8 @@ import {
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import Link from "next/link"; import Link from "next/link";
import Image from "next/image"; import Image from "next/image";
import { typography, fonts } from "@/app/_styles/theme"; import { typography, fonts } from "ui";
import LinkLogo from "@/public/link-logo-small.png"; import LinkLogo from "@/app/_images/link-logo-small.png";
import { useSession, signOut } from "next-auth/react"; import { useSession, signOut } from "next-auth/react";
const openWidth = 270; const openWidth = 270;

View file

@ -1,125 +0,0 @@
import { selectAllAction } from "@/app/_actions/service";
import { ServiceConfig } from "@/app/_lib/service";
const tableLookup = {
whatsapp: "WhatsappBot",
facebook: "FacebookBot",
signal: "SignalBot",
};
export const webhooksConfig: ServiceConfig = {
entity: "webhooks",
table: "Webhook",
displayName: "Webhook",
createFields: [
{
name: "name",
label: "Name",
required: true,
size: 12,
},
{
name: "description",
label: "Description",
size: 12,
lines: 3,
},
{
name: "httpMethod",
label: "HTTP Method",
kind: "select",
getOptions: async () => [
{ value: "post", label: "POST" },
{ value: "put", label: "PUT" },
],
defaultValue: "post",
required: true,
size: 2,
},
{
name: "endpointUrl",
label: "Endpoint",
required: true,
size: 10,
},
{
name: "backendType",
label: "Backend Type",
kind: "select",
getOptions: async (_formState: any) => [
{ value: "whatsapp", label: "WhatsApp" },
{ value: "facebook", label: "Facebook" },
{ value: "signal", label: "Signal" },
],
defaultValue: "facebook",
required: true,
},
{
name: "backendId",
label: "Backend ID",
kind: "select",
getOptions: async (formState: any) => {
console.log({ formState });
if (!formState || !formState.values.backendType) {
return [];
}
// @ts-expect-error
const table = tableLookup[formState.values.backendType];
console.log({ table });
const result = await selectAllAction(table);
console.log({ result });
return result.map((item: any) => ({
value: item.id,
label: item.name,
}));
},
required: true,
},
{
name: "headers",
label: "HTTP Headers",
kind: "multi",
size: 12,
helperText: "Useful for including authentication headers",
},
],
updateFields: [
{ name: "name", label: "Name", required: true, size: 12 },
{
name: "description",
label: "Description",
required: true,
size: 12,
},
],
displayFields: [
{ name: "name", label: "Name", required: true, size: 12 },
{
name: "description",
label: "Description",
required: true,
size: 12,
},
],
listColumns: [
{
field: "name",
headerName: "Name",
flex: 1,
},
{
field: "description",
headerName: "Description",
flex: 2,
},
{
field: "updatedAt",
headerName: "Updated At",
valueGetter: (value: any) => new Date(value).toLocaleString(),
flex: 1,
},
],
};

View file

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 4.6 KiB

Before After
Before After

View file

@ -1,6 +1,6 @@
import GoogleProvider from "next-auth/providers/google"; import GoogleProvider from "next-auth/providers/google";
import { KyselyAdapter } from "@auth/kysely-adapter"; import { KyselyAdapter } from "@auth/kysely-adapter";
import { db } from "./database"; import { db } from "bridge-common";
export const authOptions = { export const authOptions = {
// @ts-ignore // @ts-ignore

View file

@ -1,101 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { makeWorkerUtils, WorkerUtils } from "graphile-worker";
import { Service } from "./service";
import { db } from "./database";
let workerUtils: WorkerUtils;
const getWorkerUtils = async () => {
if (!workerUtils) {
workerUtils = await makeWorkerUtils({
connectionString: process.env.DATABASE_URL,
});
}
return workerUtils;
};
const sendMessage = async (req: NextRequest) => {
console.log({ req });
return NextResponse.json({ response: "ok" });
};
const receiveMessages = async (req: NextRequest) => {
console.log({ req });
return NextResponse.json({ response: "ok" });
};
const handleWebhook = async (req: NextRequest) => {
const { searchParams } = req.nextUrl;
const submittedToken = searchParams.get("hub.verify_token");
if (submittedToken) {
console.log({ submittedToken });
const row = await db
.selectFrom("FacebookBot")
.selectAll()
.where("verifyToken", "=", submittedToken)
.executeTakeFirst();
console.log({ row });
if (!row) {
return NextResponse.error();
}
if (searchParams.get("hub.mode") === "subscribe") {
const challenge = searchParams.get("hub.challenge");
console.log(submittedToken);
console.log(challenge);
return NextResponse.json(challenge) as any;
}
}
const message = await req.json();
console.log({ message });
const entry = message.entry[0];
console.log({ entry });
const messaging = entry?.messaging[0];
const pageId = messaging?.recipient?.id;
console.log({ pageId });
const row = await db
.selectFrom("FacebookBot")
.selectAll()
.where("pageId", "=", pageId)
.executeTakeFirst();
console.log({ row });
const endpoint = `https://graph.facebook.com/v19.0/${pageId}/messages`;
const inMessage = messaging?.message?.text;
const outgoingMessage = {
recipient: { id: messaging?.sender?.id },
message: { text: `"${inMessage}", right back at you!` },
messaging_type: "RESPONSE",
access_token: row?.pageAccessToken,
};
console.log({ outgoingMessage });
const response = await fetch(endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(outgoingMessage),
});
console.log({ response });
console.log(message);
const wu = await getWorkerUtils();
await wu.addJob("receive_facebook_message", message);
return NextResponse.json({ response: "ok" });
};
export const Facebook: Service = {
sendMessage,
receiveMessages,
handleWebhook,
};

View file

@ -1,17 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { getService } from "./utils";
const notFound = () => new NextResponse(null, { status: 404 });
export const getOneBot = async (req: NextRequest): Promise<NextResponse> =>
notFound();
export const sendMessage = async (req: NextRequest): Promise<NextResponse> =>
getService(req)?.sendMessage(req) ?? notFound();
export const receiveMessages = async (
req: NextRequest,
): Promise<NextResponse> => getService(req)?.receiveMessages(req) ?? notFound();
export const handleWebhook = async (req: NextRequest): Promise<NextResponse> =>
getService(req)?.handleWebhook(req) ?? notFound();

View file

@ -1,26 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { Service } from "./service";
const sendMessage = async (req: NextRequest) => {
console.log({ req });
return NextResponse.json({ response: "ok" });
};
const receiveMessages = async (req: NextRequest) => {
console.log({ req });
return NextResponse.json({ response: "ok" });
};
const handleWebhook = async (req: NextRequest) => {
console.log({ req });
return NextResponse.json({ response: "ok" });
};
export const Signal: Service = {
sendMessage,
receiveMessages,
handleWebhook,
};

View file

@ -1,13 +0,0 @@
import { NextRequest } from "next/server";
import { Service } from "./service";
import { Facebook } from "./facebook";
const services: Record<string, Service> = {
facebook: Facebook,
};
export const getService = (req: NextRequest): Service => {
const service = req.nextUrl.pathname.split("/")?.[2] ?? "none";
return services[service];
};

View file

@ -1,26 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { Service } from "./service";
const sendMessage = async (req: NextRequest) => {
console.log({ req });
return NextResponse.json({ response: "ok" });
};
const receiveMessages = async (req: NextRequest) => {
console.log({ req });
return NextResponse.json({ response: "ok" });
};
const handleWebhook = async (req: NextRequest) => {
console.log({ req });
return NextResponse.json({ response: "ok" });
};
export const Voice: Service = {
sendMessage,
receiveMessages,
handleWebhook,
};

View file

@ -1,26 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { Service } from "./service";
const sendMessage = async (req: NextRequest) => {
console.log({ req });
return NextResponse.json({ response: "ok" });
};
const receiveMessages = async (req: NextRequest) => {
console.log({ req });
return NextResponse.json({ response: "ok" });
};
const handleWebhook = async (req: NextRequest) => {
console.log({ req });
return NextResponse.json({ response: "ok" });
};
export const Whatsapp: Service = {
sendMessage,
receiveMessages,
handleWebhook,
};

View file

@ -1 +0,0 @@
export { receiveMessages as GET } from "@/app/_lib/routing";

View file

@ -1 +1 @@
export { getOneBot as GET } from "@/app/_lib/routing"; export { getBot as GET } from "bridge-ui";

View file

@ -1 +1 @@
export { sendMessage as POST } from "@/app/_lib/routing"; export { sendMessage as POST } from "bridge-ui";

View file

@ -1,6 +1,3 @@
import { NextRequest } from "next/server"; import { handleWebhook } from "bridge-ui";
import { handleWebhook } from "@/app/_lib/routing";
const handleRequest = async (req: NextRequest) => handleWebhook(req); export { handleWebhook as GET, handleWebhook as POST };
export { handleRequest as GET, handleRequest as POST };

View file

@ -1,6 +1,6 @@
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const nextConfig = { const nextConfig = {
transpilePackages: ["ui"], transpilePackages: ["ui", "bridge-common", "bridge-ui"],
}; };
export default nextConfig; export default nextConfig;

View file

@ -41,7 +41,9 @@
"react-timer-hook": "^3.0.7", "react-timer-hook": "^3.0.7",
"tss-react": "^4.9.7", "tss-react": "^4.9.7",
"tsx": "^4.7.3", "tsx": "^4.7.3",
"ui": "*" "ui": "*",
"bridge-common": "*",
"bridge-ui": "*"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^20", "@types/node": "^20",

View file

@ -15,6 +15,7 @@
"html-to-text": "^9.0.5", "html-to-text": "^9.0.5",
"jest": "^29.7.0", "jest": "^29.7.0",
"kysely": "^0.27.3", "kysely": "^0.27.3",
"bridge-common": "*",
"pg": "^8.11.5", "pg": "^8.11.5",
"remeda": "^1.61.0", "remeda": "^1.61.0",
"twilio": "^5.0.4" "twilio": "^5.0.4"

View file

@ -1,12 +1,27 @@
interface ReceiveFacebookMessageTaskOptions {} import { db, getWorkerUtils } from "bridge-common";
const receiveFacebookMessageTask = async ( interface ReceiveFacebookMessageTaskOptions {
options: ReceiveFacebookMessageTaskOptions, message: any;
): Promise<void> => { }
console.log(options);
// withDb(async (db: AppDatabase) => { const receiveFacebookMessageTask = async ({
// await notifyWebhooks(db, options); message,
// }); }: ReceiveFacebookMessageTaskOptions): Promise<void> => {
const worker = await getWorkerUtils();
for (const entry of message.entry) {
for (const messaging of entry.messaging) {
const pageId = messaging.recipient.id;
const row = await db
.selectFrom("FacebookBot")
.selectAll()
.where("pageId", "=", pageId)
.executeTakeFirstOrThrow();
console.log({ row });
await worker.addJob("notify_webhooks", messaging);
}
}
}; };
export default receiveFacebookMessageTask; export default receiveFacebookMessageTask;

View file

@ -4,6 +4,58 @@ const sendFacebookMessageTask = async (
options: SendFacebookMessageTaskOptions, options: SendFacebookMessageTaskOptions,
): Promise<void> => { ): Promise<void> => {
console.log(options); console.log(options);
const message = await req.json();
const message = await req.json();
for (const entry of message.entry) {
for (const messaging of entry.messaging) {
const pageId = messaging.recipient.id;
const row = await db
.selectFrom("FacebookBot")
.selectAll()
.where("pageId", "=", pageId)
.executeTakeFirst();
console.log({ message });
const entry = message.entry[0];
console.log({ entry });
const messaging = entry?.messaging[0];
const pageId = messaging?.recipient?.id;
console.log({ pageId });
const row = await db
.selectFrom("FacebookBot")
.selectAll()
.where("pageId", "=", pageId)
.executeTakeFirst();
console.log({ row });
const endpoint = `https://graph.facebook.com/v19.0/${pageId}/messages`;
const inMessage = messaging?.message?.text;
const outgoingMessage = {
recipient: { id: messaging?.sender?.id },
message: { text: `"${inMessage}", right back at you!` },
messaging_type: "RESPONSE",
access_token: row?.pageAccessToken,
};
console.log({ outgoingMessage });
const response = await fetch(endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(outgoingMessage),
});
console.log({ response });
console.log(message);
const wu = await getWorkerUtils();
await wu.addJob("receive_facebook_message", message);
return NextResponse.json({ response: "ok" });
// withDb(async (db: AppDatabase) => { // withDb(async (db: AppDatabase) => {
// await notifyWebhooks(db, options); // await notifyWebhooks(db, options);
// }); // });

93
package-lock.json generated
View file

@ -137,6 +137,7 @@
"license": "AGPL-3.0-or-later", "license": "AGPL-3.0-or-later",
"dependencies": { "dependencies": {
"@hapi/wreck": "^18.1.0", "@hapi/wreck": "^18.1.0",
"bridge-common": "*",
"fluent-ffmpeg": "^2.1.2", "fluent-ffmpeg": "^2.1.2",
"graphile-worker": "^0.16.5", "graphile-worker": "^0.16.5",
"html-to-text": "^9.0.5", "html-to-text": "^9.0.5",
@ -6530,6 +6531,10 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/bridge-common": {
"resolved": "packages/bridge-common",
"link": true
},
"node_modules/bridge-frontend": { "node_modules/bridge-frontend": {
"resolved": "apps/bridge-frontend", "resolved": "apps/bridge-frontend",
"link": true "link": true
@ -16336,6 +16341,94 @@
"url": "https://github.com/sponsors/wooorm" "url": "https://github.com/sponsors/wooorm"
} }
}, },
"packages/bridge-common": {
"version": "1.0.0",
"license": "AGPL-3.0-or-later",
"dependencies": {
"@auth/kysely-adapter": "^1.0.0",
"kysely": "^0.26",
"pg": "^8.11.5"
},
"devDependencies": {
"@babel/core": "7.24.4",
"@babel/preset-env": "7.24.4",
"@babel/preset-typescript": "7.24.1",
"eslint": "^9.0.0",
"prettier": "^3.2.5",
"ts-config": "*",
"typescript": "^5.4.5"
}
},
"packages/bridge-common/node_modules/@auth/kysely-adapter": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@auth/kysely-adapter/-/kysely-adapter-1.0.0.tgz",
"integrity": "sha512-gK7Vn+CAJV2ezgtqikHp/avkiM/gJlJxdYzkKxaR8CLtstHF6ao6Xi+4pF/WaE0Mp8T25NSPeXD+HPDA+jc17w==",
"dependencies": {
"@auth/core": "0.30.0"
},
"peerDependencies": {
"kysely": "^0.26.1"
}
},
"packages/bridge-common/node_modules/eslint": {
"version": "9.1.1",
"resolved": "https://registry.npmjs.org/eslint/-/eslint-9.1.1.tgz",
"integrity": "sha512-b4cRQ0BeZcSEzPpY2PjFY70VbO32K7BStTGtBsnIGdTSEEQzBi8hPBcGQmTG2zUvFr9uLe0TK42bw8YszuHEqg==",
"dev": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
"@eslint/eslintrc": "^3.0.2",
"@eslint/js": "9.1.1",
"@humanwhocodes/config-array": "^0.13.0",
"@humanwhocodes/module-importer": "^1.0.1",
"@humanwhocodes/retry": "^0.2.3",
"@nodelib/fs.walk": "^1.2.8",
"ajv": "^6.12.4",
"chalk": "^4.0.0",
"cross-spawn": "^7.0.2",
"debug": "^4.3.2",
"escape-string-regexp": "^4.0.0",
"eslint-scope": "^8.0.1",
"eslint-visitor-keys": "^4.0.0",
"espree": "^10.0.1",
"esquery": "^1.4.2",
"esutils": "^2.0.2",
"fast-deep-equal": "^3.1.3",
"file-entry-cache": "^8.0.0",
"find-up": "^5.0.0",
"glob-parent": "^6.0.2",
"ignore": "^5.2.0",
"imurmurhash": "^0.1.4",
"is-glob": "^4.0.0",
"is-path-inside": "^3.0.3",
"json-stable-stringify-without-jsonify": "^1.0.1",
"levn": "^0.4.1",
"lodash.merge": "^4.6.2",
"minimatch": "^3.1.2",
"natural-compare": "^1.4.0",
"optionator": "^0.9.3",
"strip-ansi": "^6.0.1",
"text-table": "^0.2.0"
},
"bin": {
"eslint": "bin/eslint.js"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"url": "https://opencollective.com/eslint"
}
},
"packages/bridge-common/node_modules/kysely": {
"version": "0.26.3",
"resolved": "https://registry.npmjs.org/kysely/-/kysely-0.26.3.tgz",
"integrity": "sha512-yWSgGi9bY13b/W06DD2OCDDHQmq1kwTGYlQ4wpZkMOJqMGCstVCFIvxCCVG4KfY1/3G0MhDAcZsip/Lw8/vJWw==",
"engines": {
"node": ">=14.0.0"
}
},
"packages/bridge-ui": { "packages/bridge-ui": {
"version": "1.0.0", "version": "1.0.0",
"license": "ISC" "license": "ISC"

View file

@ -0,0 +1,2 @@
export { db, type Database } from "./lib/database";
export { getWorkerUtils } from "./lib/utils";

View file

@ -0,0 +1,13 @@
import { makeWorkerUtils, WorkerUtils } from "graphile-worker";
let workerUtils: WorkerUtils;
export const getWorkerUtils = async () => {
if (!workerUtils) {
workerUtils = await makeWorkerUtils({
connectionString: process.env.DATABASE_URL,
});
}
return workerUtils;
};

View file

@ -0,0 +1,26 @@
{
"name": "bridge-common",
"version": "1.0.0",
"main": "index.js",
"type": "module",
"author": "Darren Clarke <darren@redaranj.com>",
"license": "AGPL-3.0-or-later",
"scripts": {
"build": "tsc -p tsconfig.json",
"dev": "NODE_OPTIONS=\"--loader ts-node/esm\" graphile-worker"
},
"dependencies": {
"@auth/kysely-adapter": "^1.0.0",
"kysely": "^0.26",
"pg": "^8.11.5"
},
"devDependencies": {
"@babel/core": "7.24.4",
"@babel/preset-env": "7.24.4",
"@babel/preset-typescript": "7.24.1",
"eslint": "^9.0.0",
"prettier": "^3.2.5",
"ts-config": "*",
"typescript": "^5.4.5"
}
}

View file

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

File diff suppressed because one or more lines are too long

View file

@ -1,8 +1,8 @@
"use server"; "use server";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { db, Database } from "@/app/_lib/database"; import { db, Database } from "bridge-common";
import { FieldDescription, Entity } from "@/app/_lib/service"; import { FieldDescription, Entity } from "../lib/service";
import crypto from "crypto"; import crypto from "crypto";
const generateToken = () => { const generateToken = () => {
@ -28,7 +28,6 @@ export const createAction = async ({
currentState, currentState,
formData, formData,
}: CreateActionArgs) => { }: CreateActionArgs) => {
console.log(formData);
const newRecord = fields.reduce( const newRecord = fields.reduce(
(acc: Record<string, any>, field: FieldDescription) => { (acc: Record<string, any>, field: FieldDescription) => {
if (field.autogenerated === "token") { if (field.autogenerated === "token") {
@ -42,14 +41,12 @@ export const createAction = async ({
{}, {},
); );
console.log({ newRecord });
const record = await db const record = await db
.insertInto(table) .insertInto(table)
.values(newRecord) .values(newRecord)
.returning(["id"]) .returning(["id"])
.executeTakeFirstOrThrow(); .executeTakeFirstOrThrow();
console.log({ record });
revalidatePath(`/${entity}`); revalidatePath(`/${entity}`);
return { return {

View file

@ -1,13 +1,13 @@
"use client"; "use client";
import { FC } from "react"; import { FC, useEffect, useState } from "react";
import { useFormState } from "react-dom"; import { useFormState } from "react-dom";
import { Grid } from "@mui/material"; import { Grid } from "@mui/material";
import { TextField, Select, MultiValueField } from "ui"; import { useRouter } from "next/navigation";
import { Create as InternalCreate } from "@/app/_components/Create"; import { Button, Dialog, TextField, Select, MultiValueField } from "ui";
import { generateCreateAction } from "@/app/_lib/actions"; import { generateCreateAction } from "../lib/actions";
import { serviceConfig } from "@/app/_config/config"; import { FieldDescription } from "../lib/service";
import { FieldDescription } from "@/app/_lib/service"; import { serviceConfig } from "../config/config";
type CreateProps = { type CreateProps = {
service: string; service: string;
@ -40,17 +40,45 @@ export const Create: FC<CreateProps> = ({ service }) => {
), ),
}; };
const [formState, formAction] = useFormState(createAction, initialState); const [formState, formAction] = useFormState(createAction, initialState);
const [liveFormState, setLiveFormState] = useState(formState);
const updateFormState = (field: string, value: any) => {
const newState = { ...liveFormState };
newState.values[field] = value;
setLiveFormState(newState);
};
const router = useRouter();
useEffect(() => {
if (formState.success) {
router.push(`/${entity}/${formState.values.id}`);
}
}, [formState.success, router, entity, formState.values.id]);
return ( return (
<InternalCreate <Dialog
open
title={`Create ${displayName}`} title={`Create ${displayName}`}
entity={entity}
formAction={formAction} formAction={formAction}
formState={formState} onClose={() => router.push(`/${entity}`)}
buttons={
<Grid container justifyContent="space-between">
<Grid item>
<Button
text="Cancel"
kind="secondary"
onClick={() => router.push(`/${entity}`)}
/>
</Grid>
<Grid item>
<Button text="Save" kind="primary" type="submit" />
</Grid>
</Grid>
}
> >
<Grid container direction="row" rowSpacing={3} columnSpacing={2}> <Grid container direction="row" rowSpacing={3} columnSpacing={2}>
{createFields.map( {createFields.map(
(field) => (field: FieldDescription) =>
!field.hidden && ( !field.hidden && (
<Grid key={field.name} item xs={field.size ?? 6}> <Grid key={field.name} item xs={field.size ?? 6}>
{field.kind === "select" && ( {field.kind === "select" && (
@ -58,8 +86,9 @@ export const Create: FC<CreateProps> = ({ service }) => {
name={field.name} name={field.name}
label={field.label} label={field.label}
required={field.required ?? false} required={field.required ?? false}
formState={formState} formState={liveFormState}
getOptions={field.getOptions} getOptions={field.getOptions}
updateFormState={updateFormState}
/> />
)} )}
{field.kind === "multi" && ( {field.kind === "multi" && (
@ -84,6 +113,6 @@ export const Create: FC<CreateProps> = ({ service }) => {
), ),
)} )}
</Grid> </Grid>
</InternalCreate> </Dialog>
); );
}; };

View file

@ -3,7 +3,7 @@
import { FC } from "react"; import { FC } from "react";
import { Grid, Box } from "@mui/material"; import { Grid, Box } from "@mui/material";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { typography } from "@/app/_styles/theme"; import { typography } from "@/styles/theme";
interface DeleteDialogProps { interface DeleteDialogProps {
title: string; title: string;
@ -11,7 +11,11 @@ interface DeleteDialogProps {
children: any; children: any;
} }
export const DeleteDialog: FC<DeleteDialogProps> = ({ title, entity, children }) => { export const DeleteDialog: FC<DeleteDialogProps> = ({
title,
entity,
children,
}) => {
const router = useRouter(); const router = useRouter();
const { h3 } = typography; const { h3 } = typography;
@ -22,9 +26,7 @@ export const DeleteDialog: FC<DeleteDialogProps> = ({ title, entity, children })
<Grid item> <Grid item>
<Box sx={h3}>{title}</Box> <Box sx={h3}>{title}</Box>
</Grid> </Grid>
<Grid item> <Grid item>{children}</Grid>
{children}
</Grid>
</Grid> </Grid>
</Box> </Box>
); );

View file

@ -1,25 +1,26 @@
"use client"; "use client";
import { FC, useState } from "react"; import { FC, useState } from "react";
import { Box, Grid } from "@mui/material"; import { Grid, Box } from "@mui/material";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Dialog, Button, colors, typography } from "ui"; import { DisplayTextField, Button, Dialog, colors, typography } from "ui";
import { Selectable } from "kysely";
import { type Database } from "bridge-common";
import { generateDeleteAction } from "../lib/actions";
import { serviceConfig } from "../config/config";
import { FieldDescription } from "../lib/service";
interface DetailProps { type DetailProps = {
title: string; service: string;
entity: string; row: Selectable<keyof Database>;
id: string; };
children: any;
deleteAction?: Function;
}
export const Detail: FC<DetailProps> = ({ export const Detail: FC<DetailProps> = ({ service, row }) => {
title, const {
entity, [service]: { entity, table, displayName, displayFields: fields },
id, } = serviceConfig;
children, const id = row.id as string;
deleteAction, const deleteAction = generateDeleteAction({ entity, table });
}) => {
const router = useRouter(); const router = useRouter();
const { almostBlack } = colors; const { almostBlack } = colors;
const { bodyLarge } = typography; const { bodyLarge } = typography;
@ -35,20 +36,18 @@ export const Detail: FC<DetailProps> = ({
<> <>
<Dialog <Dialog
open open
title={title} title={`${displayName} Detail`}
onClose={() => router.push(`/${entity}`)} onClose={() => router.push(`/${entity}`)}
buttons={ buttons={
<Grid container justifyContent="space-between"> <Grid container justifyContent="space-between">
<Grid item container xs="auto" spacing={2}> <Grid item container xs="auto" spacing={2}>
{deleteAction && ( <Grid item>
<Grid item> <Button
<Button text="Delete"
text="Delete" kind="destructive"
kind="destructive" onClick={() => setShowDeleteConfirmation(true)}
onClick={() => setShowDeleteConfirmation(true)} />
/> </Grid>
</Grid>
)}
<Grid item> <Grid item>
<Button <Button
text="Edit" text="Edit"
@ -63,7 +62,19 @@ export const Detail: FC<DetailProps> = ({
</Grid> </Grid>
} }
> >
{children} <Grid container direction="row" rowSpacing={3} columnSpacing={2}>
{fields.map((field: FieldDescription) => (
<Grid item xs={field.size ?? 6} key={field.name}>
<DisplayTextField
name={field.name}
label={field.label}
lines={field.lines ?? 1}
value={row[field.name] as string}
copyable={field.copyable ?? false}
/>
</Grid>
))}
</Grid>
</Dialog> </Dialog>
<Dialog <Dialog
open={showDeleteConfirmation} open={showDeleteConfirmation}

View file

@ -0,0 +1,115 @@
"use client";
import { FC, useEffect, useState } from "react";
import { useFormState } from "react-dom";
import { Grid } from "@mui/material";
import { useRouter } from "next/navigation";
import { TextField, Dialog, Button, Select, MultiValueField } from "ui";
import { Selectable } from "kysely";
import { type Database } from "bridge-common";
import { generateUpdateAction } from "../lib/actions";
import { serviceConfig } from "../config/config";
import { FieldDescription } from "../lib/service";
type EditProps = {
service: string;
row: Selectable<keyof Database>;
};
export const Edit: FC<EditProps> = ({ service, row }) => {
const {
[service]: { entity, table, displayName, updateFields },
} = serviceConfig;
const fields = updateFields.map((field: any) => {
const copy = { ...field };
Object.keys(copy).forEach((key: any) => {
if (typeof copy[key] === "function") {
delete copy[key];
}
});
return copy;
});
const updateFieldNames = fields.map((val: FieldDescription) => val.name);
const updateAction = generateUpdateAction({ entity, table, fields });
const updateValues = Object.fromEntries(
Object.entries(row).filter(([key]) => updateFieldNames.includes(key)),
);
updateValues.id = row.id;
const initialState = {
message: null,
errors: {},
values: updateValues,
};
const [formState, formAction] = useFormState(updateAction, initialState);
const router = useRouter();
const [liveFormState, setLiveFormState] = useState(formState);
const updateFormState = (field: string, value: any) => {
const newState = { ...liveFormState };
newState.values[field] = value;
setLiveFormState(newState);
};
useEffect(() => {
if (formState.success) {
router.push(`/${entity}`);
}
}, [formState.success, router, entity]);
return (
<Dialog
open
title={`Edit ${displayName}`}
formAction={formAction}
onClose={() => router.push(`/${entity}`)}
buttons={
<Grid container justifyContent="space-between">
<Grid item>
<Button
text="Cancel"
kind="secondary"
onClick={() => router.push(`/${entity}`)}
/>
</Grid>
<Grid item>
<Button text="Save" kind="primary" type="submit" />
</Grid>
</Grid>
}
>
<Grid container direction="row" rowSpacing={3} columnSpacing={2}>
{updateFields.map((field: FieldDescription) => (
<Grid key={field.name} item xs={field.size ?? 6}>
{field.kind === "select" && (
<Select
name={field.name}
label={field.label}
required={field.required ?? false}
formState={liveFormState}
getOptions={field.getOptions}
updateFormState={updateFormState}
/>
)}
{field.kind === "multi" && (
<MultiValueField
name={field.name}
label={field.label}
formState={formState}
helperText={field.helperText}
/>
)}
{(!field.kind || field.kind === "text") && (
<TextField
name={field.name}
label={field.label}
lines={field.lines ?? 1}
required={field.required ?? false}
formState={formState}
helperText={field.helperText}
/>
)}
</Grid>
))}
</Grid>
</Dialog>
);
};

View file

@ -1,20 +1,20 @@
"use client"; "use client";
import { FC } from "react"; import { FC } from "react";
import { GridColDef } from "@mui/x-data-grid-pro";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { List as InternalList, Button } from "ui"; import { List as InternalList, Button } from "ui";
import type { Selectable } from "kysely"; import { type Selectable } from "kysely";
import { Database } from "@/app/_lib/database"; import { type Database } from "bridge-common";
import { serviceConfig } from "../config/config";
interface ListProps { type ListProps = {
title: string; service: string;
entity: string;
rows: Selectable<keyof Database>[]; rows: Selectable<keyof Database>[];
columns: GridColDef<any>[]; };
}
export const List: FC<ListProps> = ({ title, entity, rows, columns }) => { export const List: FC<ListProps> = ({ service, rows }) => {
const { displayName, entity, listColumns } = serviceConfig[service];
const title = `${displayName}s`;
const router = useRouter(); const router = useRouter();
const onRowClick = (id: string) => { const onRowClick = (id: string) => {
@ -25,7 +25,7 @@ export const List: FC<ListProps> = ({ title, entity, rows, columns }) => {
<InternalList <InternalList
title={title} title={title}
rows={rows} rows={rows}
columns={columns} columns={listColumns}
onRowClick={onRowClick} onRowClick={onRowClick}
buttons={ buttons={
<Button text="Create" kind="primary" href={`/${entity}/create`} /> <Button text="Create" kind="primary" href={`/${entity}/create`} />

View file

@ -16,10 +16,10 @@ export const ServiceLayout = ({
params: { segment }, params: { segment },
}: ServiceLayoutProps) => { }: ServiceLayoutProps) => {
const length = segment?.length ?? 0; const length = segment?.length ?? 0;
const isCreate = length === 1 && segment[0] === "create"; const isCreate = length === 2 && segment[1] === "create";
const isEdit = length === 2 && segment[1] === "edit"; const isEdit = length === 3 && segment[2] === "edit";
const id = length > 0 && !isCreate ? segment[0] : null; const id = length > 0 && !isCreate ? segment[1] : null;
const isDetail = length === 1 && !!id && !isCreate && !isEdit; const isDetail = length === 2 && !!id && !isCreate && !isEdit;
return ( return (
<> <>

View file

@ -1,4 +1,5 @@
import type { ServiceConfig } from "@/app/_lib/service"; import { type Database } from "bridge-common";
import type { ServiceConfig } from "../lib/service";
import { facebookConfig as facebook } from "./facebook"; import { facebookConfig as facebook } from "./facebook";
import { signalConfig as signal } from "./signal"; import { signalConfig as signal } from "./signal";
import { whatsappConfig as whatsapp } from "./whatsapp"; import { whatsappConfig as whatsapp } from "./whatsapp";
@ -14,3 +15,19 @@ export const serviceConfig: Record<string, ServiceConfig> = {
webhooks, webhooks,
users, users,
}; };
export const getServiceTable = (service: string): keyof Database => {
const tableLookup: Record<string, keyof Database> = {
facebook: "FacebookBot",
signal: "SignalBot",
whatsapp: "WhatsappBot",
};
const table = tableLookup[service];
if (!table) {
throw new Error("Table not found");
}
return table;
};

View file

@ -1,4 +1,4 @@
import { Service, ServiceConfig } from "@/app/_lib/service"; import { ServiceConfig } from "../lib/service";
export const facebookConfig: ServiceConfig = { export const facebookConfig: ServiceConfig = {
entity: "facebook", entity: "facebook",
@ -94,7 +94,6 @@ export const facebookConfig: ServiceConfig = {
name: "pageId", name: "pageId",
label: "Page ID", label: "Page ID",
required: true, required: true,
copyable: true,
}, },
{ {
name: "pageAccessToken", name: "pageAccessToken",

View file

@ -1,4 +1,4 @@
import { ServiceConfig } from "@/app/_lib/service"; import { ServiceConfig } from "../lib/service";
export const signalConfig: ServiceConfig = { export const signalConfig: ServiceConfig = {
entity: "signal", entity: "signal",

View file

@ -1,4 +1,4 @@
import { ServiceConfig } from "@/app/_lib/service"; import { ServiceConfig } from "../lib/service";
export const usersConfig: ServiceConfig = { export const usersConfig: ServiceConfig = {
entity: "users", entity: "users",

View file

@ -1,4 +1,4 @@
import { ServiceConfig } from "@/app/_lib/service"; import { ServiceConfig } from "../lib/service";
export const voiceConfig: ServiceConfig = { export const voiceConfig: ServiceConfig = {
entity: "voice", entity: "voice",

View file

@ -0,0 +1,163 @@
import { selectAllAction } from "../actions/service";
import { ServiceConfig } from "../lib/service";
import { getServiceTable } from "../config/config";
const getHTTPMethodOptions = async () => [
{ value: "post", label: "POST" },
{ value: "put", label: "PUT" },
];
const getBackendTypeOptions = async (_formState: any) => [
{ value: "whatsapp", label: "WhatsApp" },
{ value: "facebook", label: "Facebook" },
{ value: "signal", label: "Signal" },
];
const getBackendIDOptions = async (formState: any) => {
if (!formState || !formState.values.backendType) {
return [];
}
const table = getServiceTable(formState.values.backendType);
const result = await selectAllAction(table);
return result.map((item: any) => ({
value: item.id,
label: item.name,
}));
};
export const webhooksConfig: ServiceConfig = {
entity: "webhooks",
table: "Webhook",
displayName: "Webhook",
createFields: [
{
name: "name",
label: "Name",
required: true,
size: 12,
},
{
name: "description",
label: "Description",
size: 12,
lines: 3,
},
{
name: "httpMethod",
label: "HTTP Method",
kind: "select",
getOptions: getHTTPMethodOptions,
defaultValue: "post",
required: true,
size: 2,
},
{
name: "endpointUrl",
label: "Endpoint",
required: true,
size: 10,
},
{
name: "backendType",
label: "Backend Type",
kind: "select",
getOptions: getBackendTypeOptions,
defaultValue: "facebook",
required: true,
},
{
name: "backendId",
label: "Backend ID",
kind: "select",
getOptions: getBackendIDOptions,
required: true,
},
{
name: "headers",
label: "HTTP Headers",
kind: "multi",
size: 12,
helperText: "Useful for authentication, etc.",
},
],
updateFields: [
{
name: "name",
label: "Name",
required: true,
size: 12,
},
{
name: "description",
label: "Description",
size: 12,
lines: 3,
},
{
name: "httpMethod",
label: "HTTP Method",
kind: "select",
getOptions: getHTTPMethodOptions,
defaultValue: "post",
required: true,
size: 2,
},
{
name: "endpointUrl",
label: "Endpoint",
required: true,
size: 10,
},
{
name: "backendType",
label: "Backend Type",
kind: "select",
getOptions: getBackendTypeOptions,
defaultValue: "facebook",
required: true,
},
{
name: "backendId",
label: "Backend ID",
kind: "select",
getOptions: getBackendIDOptions,
required: true,
},
{
name: "headers",
label: "HTTP Headers",
kind: "multi",
size: 12,
helperText: "Useful for authentication, etc.",
},
],
displayFields: [
{ name: "name", label: "Name", required: true, size: 12 },
{
name: "description",
label: "Description",
required: true,
size: 12,
},
],
listColumns: [
{
field: "name",
headerName: "Name",
flex: 1,
},
{
field: "description",
headerName: "Description",
flex: 2,
},
{
field: "updatedAt",
headerName: "Updated At",
valueGetter: (value: any) => new Date(value).toLocaleString(),
flex: 1,
},
],
};

View file

@ -1,4 +1,4 @@
import { ServiceConfig } from "@/app/_lib/service"; import { ServiceConfig } from "../lib/service";
export const whatsappConfig: ServiceConfig = { export const whatsappConfig: ServiceConfig = {
entity: "whatsapp", entity: "whatsapp",

16
packages/bridge-ui/images.d.ts vendored Normal file
View file

@ -0,0 +1,16 @@
declare module "*.jpg" {
const value: any;
export default value;
}
declare module "*.jpeg" {
const value: any;
export default value;
}
declare module "*.svg" {
const value: any;
export default value;
}
declare module "*.png" {
const value: any;
export default value;
}

View file

@ -0,0 +1,7 @@
export { List } from "./components/List";
export { Create } from "./components/Create";
export { Edit } from "./components/Edit";
export { Detail } from "./components/Detail";
export { ServiceLayout } from "./components/ServiceLayout";
export { serviceConfig, getServiceTable } from "./config/config";
export { getBot, sendMessage, handleWebhook } from "./lib/routing";

View file

@ -1,11 +1,11 @@
import { Database } from "./database"; import { Database } from "bridge-common";
import { FieldDescription, Entity } from "./service";
import { import {
createAction, createAction,
updateAction, updateAction,
deleteAction, deleteAction,
selectAllAction, selectAllAction,
} from "@/app/_actions/service"; } from "../actions/service";
import { FieldDescription, Entity } from "./service";
type GenerateCreateActionArgs = { type GenerateCreateActionArgs = {
entity: Entity; entity: Entity;
@ -19,8 +19,6 @@ export function generateCreateAction({
fields, fields,
}: GenerateCreateActionArgs) { }: GenerateCreateActionArgs) {
return async (currentState: any, formData: FormData) => { return async (currentState: any, formData: FormData) => {
console.log({ entity, table, fields });
console.log({ currentState, formData });
return createAction({ return createAction({
entity, entity,
table, table,

View file

@ -0,0 +1,32 @@
import { NextRequest, NextResponse } from "next/server";
import { Service } from "./service";
import { db, getWorkerUtils } from "bridge-common";
export class Facebook extends Service {
async handleWebhook(req: NextRequest) {
const { searchParams } = req.nextUrl;
const submittedToken = searchParams.get("hub.verify_token");
if (submittedToken) {
await db
.selectFrom("FacebookBot")
.selectAll()
.where("verifyToken", "=", submittedToken)
.executeTakeFirstOrThrow();
if (searchParams.get("hub.mode") === "subscribe") {
const challenge = searchParams.get("hub.challenge");
return NextResponse.json(challenge) as any;
} else {
return NextResponse.error();
}
}
const message = await req.json();
const worker = await getWorkerUtils();
await worker.addJob("receive_facebook_message", message);
return NextResponse.json({ response: "ok" });
}
}

View file

@ -0,0 +1,18 @@
import { NextRequest, NextResponse } from "next/server";
import { ServiceParams } from "./service";
import { getService } from "./utils";
export const getBot = async (
_req: NextRequest,
params: ServiceParams,
): Promise<NextResponse> => getService(params)?.getBot(params);
export const sendMessage = async (
req: NextRequest,
params: ServiceParams,
): Promise<NextResponse> => getService(params)?.sendMessage(req, params);
export const handleWebhook = async (
req: NextRequest,
params: ServiceParams,
): Promise<NextResponse> => getService(params)?.handleWebhook(req);

View file

@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server"; import { NextRequest, NextResponse } from "next/server";
import { GridColDef } from "@mui/x-data-grid-pro"; import { GridColDef } from "@mui/x-data-grid-pro";
import { Database } from "./database"; import { Database, db, getWorkerUtils } from "bridge-common";
import { getServiceTable } from "../config/config";
const entities = [ const entities = [
"facebook", "facebook",
@ -46,18 +47,35 @@ export type ServiceConfig = {
listColumns: GridColDef[]; listColumns: GridColDef[];
}; };
export type ServiceParams = {
service: string;
token?: string;
};
export class Service { export class Service {
sendMessage: (req: NextRequest) => Promise<NextResponse> = async (req) => { async getBot({ service, token }: ServiceParams): Promise<NextResponse> {
return NextResponse.json({ ok: "nice" }); const table = getServiceTable(service);
}; const row = await db
.selectFrom(table)
.selectAll()
.where("token", "=", token ?? "NEVER_MATCH")
.executeTakeFirstOrThrow();
receiveMessages: (req: NextRequest) => Promise<NextResponse> = async ( return NextResponse.json(row);
req, }
) => {
return NextResponse.json({ ok: "nice" });
};
handleWebhook: (req: NextRequest) => Promise<NextResponse> = async (req) => { async sendMessage(
return NextResponse.json({ ok: "nice" }); req: NextRequest,
}; { service, token }: ServiceParams,
): Promise<NextResponse> {
const message = await req.json();
const worker = await getWorkerUtils();
await worker.addJob(`send_${service}_message`, { token, message });
return NextResponse.json({ response: "ok" });
}
async handleWebhook(_req: NextRequest): Promise<NextResponse> {
return NextResponse.error() as any;
}
} }

View file

@ -0,0 +1,3 @@
import { Service } from "./service";
export class Signal extends Service {}

View file

@ -0,0 +1,16 @@
import { Service, ServiceParams } from "./service";
import { Facebook } from "./facebook";
import { Signal } from "./signal";
import { Whatsapp } from "./whatsapp";
export const getService = ({ service }: ServiceParams): Service => {
if (service === "facebook") {
return new Facebook();
} else if (service === "signal") {
return new Signal();
} else if (service === "whatsapp") {
return new Whatsapp();
}
throw new Error("Service not found");
};

View file

@ -0,0 +1,3 @@
import { Service } from "./service";
export class Voice extends Service {}

View file

@ -0,0 +1,3 @@
import { Service } from "./service";
export class Whatsapp extends Service {}

View file

@ -1,11 +1,46 @@
{ {
"name": "bridge-ui", "name": "bridge-ui",
"version": "1.0.0", "version": "0.2.0",
"description": "",
"main": "index.js",
"scripts": { "scripts": {
"test": "echo \"Error: no test specified\" && exit 1" "build": "tsc -p tsconfig.json"
}, },
"author": "", "dependencies": {
"license": "ISC" "@emotion/cache": "^11.11.0",
"@emotion/react": "^11.11.4",
"@emotion/server": "^11.11.0",
"@emotion/styled": "^11.11.5",
"@mui/icons-material": "^5",
"@mui/lab": "^5.0.0-alpha.170",
"@mui/material": "^5",
"@mui/x-data-grid-pro": "^7.3.1",
"@mui/x-date-pickers-pro": "^7.3.1",
"date-fns": "^3.6.0",
"material-ui-popup-state": "^5.1.0",
"next": "14.2.3",
"react": "18.3.0",
"react-cookie": "^7.1.4",
"react-cookie-consent": "^9.0.0",
"react-dom": "18.3.0",
"react-iframe": "^1.8.5",
"react-markdown": "^9.0.1",
"react-polyglot": "^0.7.2",
"tss-react": "^4.9.7",
"uuid": "^9.0.1"
},
"devDependencies": {
"@babel/core": "^7.24.4",
"@types/node": "^20.12.7",
"@types/react": "18.3.0",
"@types/uuid": "^9.0.8",
"babel-loader": "^9.1.3",
"eslint": "^8.0.0",
"eslint-config-next": "^14.2.3",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-import": "^2.29.1",
"eslint-plugin-jsx-a11y": "^6.8.0",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-react": "^7.34.1",
"file-loader": "^6.2.0",
"typescript": "5.4.5"
}
} }

View file

@ -0,0 +1,26 @@
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

File diff suppressed because one or more lines are too long

View file

@ -12,6 +12,7 @@ type SelectProps = {
formState: Record<string, any>; formState: Record<string, any>;
required?: boolean; required?: boolean;
getOptions?: (formState: any) => Promise<SelectOption[]>; getOptions?: (formState: any) => Promise<SelectOption[]>;
updateFormState?: (field: string, value: any) => void;
}; };
export const Select: FC<SelectProps> = ({ export const Select: FC<SelectProps> = ({
@ -20,6 +21,7 @@ export const Select: FC<SelectProps> = ({
formState, formState,
required = false, required = false,
getOptions, getOptions,
updateFormState,
}) => { }) => {
const [options, setOptions] = useState([] as SelectOption[]); const [options, setOptions] = useState([] as SelectOption[]);
@ -42,7 +44,8 @@ export const Select: FC<SelectProps> = ({
required={required} required={required}
size="small" size="small"
inputProps={{ id: name }} inputProps={{ id: name }}
defaultValue={formState.values[name] || ""} value={formState.values[name] || ""}
onChange={(e: any) => updateFormState?.(name, e.target.value)}
sx={{ sx={{
backgroundColor: "#fff", backgroundColor: "#fff",
}} }}

File diff suppressed because one or more lines are too long