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

@ -0,0 +1,114 @@
"use server";
import { revalidatePath } from "next/cache";
import { db, Database } from "bridge-common";
import { FieldDescription, Entity } from "../lib/service";
import crypto from "crypto";
const generateToken = () => {
const length = 20;
const randomBytes = crypto.randomBytes(length);
const randomString = randomBytes.toString("hex").slice(0, length);
return randomString;
};
type CreateActionArgs = {
entity: Entity;
table: keyof Database;
fields: FieldDescription[];
currentState: any;
formData: FormData;
};
export const createAction = async ({
entity,
table,
fields,
currentState,
formData,
}: CreateActionArgs) => {
const newRecord = fields.reduce(
(acc: Record<string, any>, field: FieldDescription) => {
if (field.autogenerated === "token") {
acc[field.name] = generateToken();
return acc;
}
acc[field.name] = formData.get(field.name)?.toString() ?? null;
return acc;
},
{},
);
const record = await db
.insertInto(table)
.values(newRecord)
.returning(["id"])
.executeTakeFirstOrThrow();
revalidatePath(`/${entity}`);
return {
...currentState,
values: { ...newRecord, id: record.id },
success: true,
};
};
type UpdateActionArgs = {
entity: Entity;
table: keyof Database;
fields: FieldDescription[];
currentState: any;
formData: FormData;
};
export const updateAction = async ({
entity,
table,
fields,
currentState,
formData,
}: UpdateActionArgs) => {
const id = currentState.values.id;
const updatedRecord = fields.reduce(
(acc: Record<string, any>, field: FieldDescription) => {
acc[field.name] = formData.get(field.name)?.toString() ?? null;
return acc;
},
{},
);
await db
.updateTable(table)
.set(updatedRecord)
.where("id", "=", id)
.executeTakeFirst();
revalidatePath(`/${entity}/${id}`);
return {
...currentState,
values: updatedRecord,
success: true,
};
};
type DeleteActionArgs = {
entity: Entity;
table: keyof Database;
id: string;
};
export const deleteAction = async ({ entity, table, id }: DeleteActionArgs) => {
await db.deleteFrom(table).where("id", "=", id).execute();
revalidatePath(`/${entity}`);
return true;
};
export const selectAllAction = async (table: keyof Database) => {
return db.selectFrom(table).selectAll().execute();
};

View file

@ -0,0 +1,118 @@
"use client";
import { FC, useEffect, useState } from "react";
import { useFormState } from "react-dom";
import { Grid } from "@mui/material";
import { useRouter } from "next/navigation";
import { Button, Dialog, TextField, Select, MultiValueField } from "ui";
import { generateCreateAction } from "../lib/actions";
import { FieldDescription } from "../lib/service";
import { serviceConfig } from "../config/config";
type CreateProps = {
service: string;
};
export const Create: FC<CreateProps> = ({ service }) => {
const {
[service]: { entity, table, displayName, createFields },
} = serviceConfig;
const fields = createFields.map((field: any) => {
const copy = { ...field };
Object.keys(copy).forEach((key: any) => {
if (typeof copy[key] === "function") {
delete copy[key];
}
});
return copy;
});
const createAction = generateCreateAction({ entity, table, fields });
const initialState = {
message: null,
errors: {},
values: fields.reduce(
(acc: Record<string, any>, field: FieldDescription) => {
acc[field.name] = field.defaultValue;
return acc;
},
{},
),
};
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 (
<Dialog
open
title={`Create ${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}>
{createFields.map(
(field: FieldDescription) =>
!field.hidden && (
<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

@ -0,0 +1,33 @@
"use client";
import { FC } from "react";
import { Grid, Box } from "@mui/material";
import { useRouter } from "next/navigation";
import { typography } from "@/styles/theme";
interface DeleteDialogProps {
title: string;
entity: string;
children: any;
}
export const DeleteDialog: FC<DeleteDialogProps> = ({
title,
entity,
children,
}) => {
const router = useRouter();
const { h3 } = typography;
return (
<Box sx={{ height: "100vh", backgroundColor: "#ddd", p: 3 }}>
<Grid container direction="column">
<Grid item>
<Box sx={h3}>{title}</Box>
</Grid>
<Grid item>{children}</Grid>
</Grid>
</Box>
);
};

View file

@ -0,0 +1,108 @@
"use client";
import { FC, useState } from "react";
import { Grid, Box } from "@mui/material";
import { useRouter } from "next/navigation";
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";
type DetailProps = {
service: string;
row: Selectable<keyof Database>;
};
export const Detail: FC<DetailProps> = ({ service, row }) => {
const {
[service]: { entity, table, displayName, displayFields: fields },
} = serviceConfig;
const id = row.id as string;
const deleteAction = generateDeleteAction({ entity, table });
const router = useRouter();
const { almostBlack } = colors;
const { bodyLarge } = typography;
const [showDeleteConfirmation, setShowDeleteConfirmation] = useState(false);
const continueDeleteAction = async () => {
await deleteAction?.(id);
setShowDeleteConfirmation(false);
router.push(`/${entity}`);
};
return (
<>
<Dialog
open
title={`${displayName} Detail`}
onClose={() => router.push(`/${entity}`)}
buttons={
<Grid container justifyContent="space-between">
<Grid item container xs="auto" spacing={2}>
<Grid item>
<Button
text="Delete"
kind="destructive"
onClick={() => setShowDeleteConfirmation(true)}
/>
</Grid>
<Grid item>
<Button
text="Edit"
kind="secondary"
href={`/${entity}/${id}/edit`}
/>
</Grid>
</Grid>
<Grid item>
<Button text="Done" kind="primary" href={`/${entity}`} />
</Grid>
</Grid>
}
>
<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
open={showDeleteConfirmation}
size="xs"
title="Really delete?"
buttons={
<Grid container justifyContent="space-between">
<Grid item>
<Button
text="Cancel"
kind="secondary"
onClick={() => setShowDeleteConfirmation(false)}
/>
</Grid>
<Grid item>
<Button
text="Delete"
kind="destructive"
onClick={continueDeleteAction}
/>
</Grid>
</Grid>
}
>
<Box sx={{ ...bodyLarge, color: almostBlack }}>
Are you sure you want to delete this record?
</Box>
</Dialog>
</>
);
};

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

@ -0,0 +1,35 @@
"use client";
import { FC } from "react";
import { useRouter } from "next/navigation";
import { List as InternalList, Button } from "ui";
import { type Selectable } from "kysely";
import { type Database } from "bridge-common";
import { serviceConfig } from "../config/config";
type ListProps = {
service: string;
rows: Selectable<keyof Database>[];
};
export const List: FC<ListProps> = ({ service, rows }) => {
const { displayName, entity, listColumns } = serviceConfig[service];
const title = `${displayName}s`;
const router = useRouter();
const onRowClick = (id: string) => {
router.push(`/${entity}/${id}`);
};
return (
<InternalList
title={title}
rows={rows}
columns={listColumns}
onRowClick={onRowClick}
buttons={
<Button text="Create" kind="primary" href={`/${entity}/create`} />
}
/>
);
};

View file

@ -0,0 +1,32 @@
type ServiceLayoutProps = {
children: any;
detail: any;
edit: any;
create: any;
params: {
segment: string[];
};
};
export const 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 > 0 && !isCreate ? segment[1] : null;
const isDetail = length === 2 && !!id && !isCreate && !isEdit;
return (
<>
{children}
{isDetail && detail}
{isEdit && edit}
{isCreate && create}
</>
);
};

View file

@ -0,0 +1,33 @@
import { type Database } from "bridge-common";
import type { ServiceConfig } from "../lib/service";
import { facebookConfig as facebook } from "./facebook";
import { signalConfig as signal } from "./signal";
import { whatsappConfig as whatsapp } from "./whatsapp";
import { voiceConfig as voice } from "./voice";
import { webhooksConfig as webhooks } from "./webhooks";
import { usersConfig as users } from "./users";
export const serviceConfig: Record<string, ServiceConfig> = {
facebook,
signal,
whatsapp,
voice,
webhooks,
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

@ -0,0 +1,122 @@
import { ServiceConfig } from "../lib/service";
export const facebookConfig: ServiceConfig = {
entity: "facebook",
table: "FacebookBot",
displayName: "Facebook Connection",
createFields: [
{
name: "name",
label: "Name",
required: true,
size: 12,
},
{
name: "description",
label: "Description",
size: 12,
lines: 3,
},
{ name: "appId", label: "App ID", required: true },
{ name: "appSecret", label: "App Secret", required: true },
{ name: "pageId", label: "Page ID", required: true },
{
name: "pageAccessToken",
label: "Page Access Token",
required: true,
},
{
name: "token",
label: "Token",
hidden: true,
required: true,
autogenerated: "token",
},
{
name: "verifyToken",
label: "Verify Token",
hidden: true,
required: true,
autogenerated: "token",
},
],
updateFields: [
{ name: "name", label: "Name", required: true, size: 12 },
{
name: "description",
label: "Description",
size: 12,
lines: 3,
},
{
name: "token",
label: "Token",
disabled: true,
refreshable: true,
},
{
name: "verifyToken",
label: "Verify Token",
disabled: true,
refreshable: true,
},
{ name: "appId", label: "App ID", required: true },
{ name: "appSecret", label: "App Secret", required: true },
{ name: "pageId", label: "Page ID", required: true },
{
name: "pageAccessToken",
label: "Page Access Token",
required: true,
},
],
displayFields: [
{ name: "name", label: "Name", required: true, size: 12 },
{
name: "description",
label: "Description",
required: true,
size: 12,
},
{
name: "token",
label: "Token",
copyable: true,
},
{
name: "verifyToken",
label: "Verify Token",
copyable: true,
},
{ name: "appId", label: "App ID", required: true },
{ name: "appSecret", label: "App Secret", required: true },
{
name: "pageId",
label: "Page ID",
required: true,
},
{
name: "pageAccessToken",
label: "Page Access Token",
required: true,
},
],
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

@ -0,0 +1,86 @@
import { ServiceConfig } from "../lib/service";
export const signalConfig: ServiceConfig = {
entity: "signal",
table: "SignalBot",
displayName: "Signal Connection",
createFields: [
{
name: "name",
label: "Name",
required: true,
size: 12,
},
{
name: "description",
label: "Description",
size: 12,
lines: 3,
},
{
name: "phoneNumber",
label: "phoneNumber",
required: true,
},
{
name: "token",
label: "Token",
hidden: true,
required: true,
autogenerated: "token",
},
],
updateFields: [
{ name: "name", label: "Name", required: true, size: 12 },
{
name: "description",
label: "Description",
size: 12,
},
{
name: "phoneNumber",
label: "phoneNumber",
required: true,
},
],
displayFields: [
{ name: "name", label: "Name", required: true, size: 12 },
{
name: "description",
label: "Description",
size: 12,
},
{
name: "phoneNumber",
label: "phoneNumber",
},
{
name: "token",
label: "Token",
copyable: true,
},
],
listColumns: [
{
field: "name",
headerName: "Name",
flex: 1,
},
{
field: "phoneNumber",
headerName: "Phone Number",
flex: 1,
},
{
field: "description",
headerName: "Description",
flex: 2,
},
{
field: "updatedAt",
headerName: "Updated At",
valueGetter: (value: any) => new Date(value).toLocaleString(),
flex: 1,
},
],
};

View file

@ -0,0 +1,56 @@
import { ServiceConfig } from "../lib/service";
export const usersConfig: ServiceConfig = {
entity: "users",
table: "User",
displayName: "User",
createFields: [
{
name: "name",
label: "Name",
required: true,
size: 12,
},
{
name: "email",
label: "Email",
required: true,
size: 12,
},
],
updateFields: [
{ name: "name", label: "Name", required: true, size: 12 },
{
name: "email",
label: "Email",
required: true,
size: 12,
},
],
displayFields: [
{ name: "name", label: "Name", required: true, size: 12 },
{
name: "email",
label: "Email",
size: 12,
},
],
listColumns: [
{
field: "name",
headerName: "Name",
flex: 1,
},
{
field: "email",
headerName: "Email",
flex: 2,
},
{
field: "updatedAt",
headerName: "Updated At",
valueGetter: (value: any) => new Date(value).toLocaleString(),
flex: 1,
},
],
};

View file

@ -0,0 +1,77 @@
import { ServiceConfig } from "../lib/service";
export const voiceConfig: ServiceConfig = {
entity: "voice",
table: "VoiceLine",
displayName: "Voice Line",
createFields: [
{
name: "name",
label: "Name",
required: true,
size: 12,
},
{
name: "description",
label: "Description",
size: 12,
lines: 3,
},
{
name: "phoneNumber",
label: "phoneNumber",
required: true,
},
],
updateFields: [
{ name: "name", label: "Name", required: true, size: 12 },
{
name: "description",
label: "Description",
required: true,
size: 12,
},
{
name: "phoneNumber",
label: "Phone Number",
required: true,
},
],
displayFields: [
{ name: "name", label: "Name", required: true, size: 12 },
{
name: "description",
label: "Description",
required: true,
size: 12,
},
{
name: "phoneNumber",
label: "Phone Number",
required: true,
},
],
listColumns: [
{
field: "name",
headerName: "Name",
flex: 1,
},
{
field: "phoneNumber",
headerName: "Phone Number",
flex: 1,
},
{
field: "description",
headerName: "Description",
flex: 2,
},
{
field: "updatedAt",
headerName: "Updated At",
valueGetter: (value: any) => new Date(value).toLocaleString(),
flex: 1,
},
],
};

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

@ -0,0 +1,86 @@
import { ServiceConfig } from "../lib/service";
export const whatsappConfig: ServiceConfig = {
entity: "whatsapp",
table: "WhatsappBot",
displayName: "WhatsApp Connection",
createFields: [
{
name: "name",
label: "Name",
required: true,
size: 12,
},
{
name: "description",
label: "Description",
size: 12,
lines: 3,
},
{
name: "phoneNumber",
label: "Phone Number",
required: true,
},
{
name: "token",
label: "Token",
hidden: true,
required: true,
autogenerated: "token",
},
],
updateFields: [
{ name: "name", label: "Name", required: true, size: 12 },
{
name: "description",
label: "Description",
size: 12,
},
{
name: "phoneNumber",
label: "Phone Number",
required: true,
},
],
displayFields: [
{ name: "name", label: "Name", required: true, size: 12 },
{
name: "description",
label: "Description",
size: 12,
},
{
name: "phoneNumber",
label: "Phone Number",
},
{
name: "token",
label: "Token",
copyable: true,
},
],
listColumns: [
{
field: "name",
headerName: "Name",
flex: 1,
},
{
field: "phoneNumber",
headerName: "Phone Number",
flex: 1,
},
{
field: "description",
headerName: "Description",
flex: 2,
},
{
field: "updatedAt",
headerName: "Updated At",
valueGetter: (value: any) => new Date(value).toLocaleString(),
flex: 1,
},
],
};

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

@ -0,0 +1,72 @@
import { Database } from "bridge-common";
import {
createAction,
updateAction,
deleteAction,
selectAllAction,
} from "../actions/service";
import { FieldDescription, Entity } from "./service";
type GenerateCreateActionArgs = {
entity: Entity;
table: keyof Database;
fields: FieldDescription[];
};
export function generateCreateAction({
entity,
table,
fields,
}: GenerateCreateActionArgs) {
return async (currentState: any, formData: FormData) => {
return createAction({
entity,
table,
fields,
currentState,
formData,
});
};
}
type GenerateUpdateActionArgs = {
entity: Entity;
table: keyof Database;
fields: FieldDescription[];
};
export function generateUpdateAction({
entity,
table,
fields,
}: GenerateUpdateActionArgs) {
return async (currentState: any, formData: FormData) => {
return updateAction({
entity,
table,
fields,
currentState,
formData,
});
};
}
type GenerateDeleteActionArgs = {
entity: Entity;
table: keyof Database;
};
export function generateDeleteAction({
entity,
table,
}: GenerateDeleteActionArgs) {
return async (id: string) => {
return deleteAction({ entity, table, id });
};
}
export function generateSelectAllAction(table: keyof Database) {
return async () => {
return selectAllAction(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

@ -0,0 +1,81 @@
import { NextRequest, NextResponse } from "next/server";
import { GridColDef } from "@mui/x-data-grid-pro";
import { Database, db, getWorkerUtils } from "bridge-common";
import { getServiceTable } from "../config/config";
const entities = [
"facebook",
"whatsapp",
"signal",
"voice",
"webhooks",
"users",
] as const;
export type Entity = (typeof entities)[number];
export type SelectOption = {
value: string;
label: string;
};
export type FieldDescription = {
name: string;
label: string;
kind?: "text" | "phone" | "select" | "multi";
getOptions?: (formState: any) => Promise<SelectOption[]>;
autogenerated?: "token";
hidden?: boolean;
type?: string;
lines?: number;
copyable?: boolean;
refreshable?: boolean;
defaultValue?: string;
required?: boolean;
disabled?: boolean;
size?: number;
helperText?: string;
};
export type ServiceConfig = {
entity: Entity;
table: keyof Database;
displayName: string;
createFields: FieldDescription[];
updateFields: FieldDescription[];
displayFields: FieldDescription[];
listColumns: GridColDef[];
};
export type ServiceParams = {
service: string;
token?: string;
};
export class Service {
async getBot({ service, token }: ServiceParams): Promise<NextResponse> {
const table = getServiceTable(service);
const row = await db
.selectFrom(table)
.selectAll()
.where("token", "=", token ?? "NEVER_MATCH")
.executeTakeFirstOrThrow();
return NextResponse.json(row);
}
async sendMessage(
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",
"version": "1.0.0",
"description": "",
"main": "index.js",
"version": "0.2.0",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
"build": "tsc -p tsconfig.json"
},
"author": "",
"license": "ISC"
"dependencies": {
"@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,112 @@
import { Roboto, Playfair_Display, Poppins } from "next/font/google";
const roboto = Roboto({
weight: ["400"],
subsets: ["latin"],
display: "swap",
});
const playfair = Playfair_Display({
weight: ["900"],
subsets: ["latin"],
display: "swap",
});
const poppins = Poppins({
weight: ["400", "700"],
subsets: ["latin"],
display: "swap",
});
export const fonts = {
roboto,
playfair,
poppins,
};
export const colors: any = {
lightGray: "#ededf0",
mediumGray: "#e3e5e5",
darkGray: "#33302f",
mediumBlue: "#4285f4",
green: "#349d7b",
lavender: "#a5a6f6",
darkLavender: "#5d5fef",
pink: "#fcddec",
cdrLinkOrange: "#ff7115",
coreYellow: "#fac942",
helpYellow: "#fff4d5",
dwcDarkBlue: "#191847",
hazyMint: "#ecf7f8",
leafcutterElectricBlue: "#4d6aff",
leafcutterLightBlue: "#fafbfd",
waterbearElectricPurple: "#332c83",
waterbearLightSmokePurple: "#eff3f8",
bumpedPurple: "#212058",
mutedPurple: "#373669",
warningPink: "#ef5da8",
lightPink: "#fff0f7",
lightGreen: "#f0fff3",
lightOrange: "#fff5f0",
beige: "#f6f2f1",
almostBlack: "#33302f",
white: "#ffffff",
};
export const typography: any = {
h1: {
fontFamily: playfair.style.fontFamily,
fontSize: 45,
fontWeight: 700,
lineHeight: 1.1,
margin: 0,
},
h2: {
fontFamily: poppins.style.fontFamily,
fontSize: 35,
fontWeight: 700,
lineHeight: 1.1,
margin: 0,
},
h3: {
fontFamily: poppins.style.fontFamily,
fontWeight: 400,
fontSize: 27,
lineHeight: 1.1,
margin: 0,
},
h4: {
fontFamily: poppins.style.fontFamily,
fontWeight: 700,
fontSize: 18,
},
h5: {
fontFamily: roboto.style.fontFamily,
fontWeight: 700,
fontSize: 16,
lineHeight: "24px",
textTransform: "uppercase",
textAlign: "center",
margin: 1,
},
h6: {
fontFamily: roboto.style.fontFamily,
fontWeight: 400,
fontSize: 14,
textAlign: "center",
},
p: {
fontFamily: roboto.style.fontFamily,
fontSize: 17,
lineHeight: "26.35px",
fontWeight: 400,
margin: 0,
},
small: {
fontFamily: roboto.style.fontFamily,
fontSize: 13,
lineHeight: "18px",
fontWeight: 400,
margin: 0,
},
};

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