Compare commits

...

5 Commits

Author SHA1 Message Date
838ca2cb23 remove cleanup for now
All checks were successful
Build and push image for doorman-homeassistant / docker (push) Successful in 43s
Build and push Doorman UI / API / docker (push) Successful in 2m34s
Build and push image for doorman-homeassistant / deploy-gitainer (push) Successful in 6s
2025-10-08 14:22:05 -07:00
975c47b174 add buzzer code identification to onboarding flow 2025-10-08 14:02:53 -07:00
1bca8cf9dc implement best efforts cleanup 2025-10-08 14:01:53 -07:00
29786f5780 add check OTP api 2025-10-08 13:52:19 -07:00
946ec544fe add audio instructions 2025-10-04 15:11:01 -07:00
8 changed files with 375 additions and 25 deletions

View File

@ -0,0 +1,53 @@
/**
* Try to get buzzer number for a given OTP
*/
import { ServerlessEventObject, ServerlessFunctionSignature } from "@twilio-labs/serverless-runtime-types/types";
import { TwilioContext } from "../../../types/TwilioContext";
import { createDynaBridgeClient } from "../../../utils/ddb";
import { withMetrics } from "../../../common/DoormanHandler";
import { z } from "zod";
import { UserAgentHeader } from "../../../utils/blockUserAgent";
import { setResponseJson } from "../../../utils/responseUtils";
import { getLogCallID } from "../../../schema/LogCall";
import { isTTLInFuture } from "../../../common/TTLHelper";
export const CheckOtpRequestSchema = z.object({
otp: z.string(),
});
export type CheckOtpRequest = z.infer<typeof CheckOtpRequestSchema>;
export interface CheckOtpRequestTwilio extends ServerlessEventObject<CheckOtpRequest, UserAgentHeader> { };
export const CheckOtpResponseSchema = z.object({
buzzer: z.string(),
});
export type CheckOtpResponse = z.infer<typeof CheckOtpResponseSchema>;
export const handler: ServerlessFunctionSignature<TwilioContext, CheckOtpRequestTwilio> = withMetrics("checkotp", async (context, event, callback, metricsRegistry) => {
const response = new Twilio.Response();
const req = CheckOtpRequestSchema.parse(event);
let otp = req.otp;
const db = createDynaBridgeClient(context);
const log = await db.entities.logCall.findById(getLogCallID(otp));
if (!log || !isTTLInFuture(log)) {
setResponseJson(response, 404, {
msg: "OTP expired or not found",
})
} else {
setResponseJson(response, 200, {
buzzer: log.caller,
});
}
// destroy the internal client after
// @ts-ignore
db.ddbClient.destroy();
return callback(null, response);
});

View File

@ -13,6 +13,8 @@ import { UserAgentHeader } from "../../../utils/blockUserAgent";
import { setResponseJson } from "../../../utils/responseUtils"; import { setResponseJson } from "../../../utils/responseUtils";
import { LOG_CALL_SK, LogCallSchema } from "../../../schema/LogCall"; import { LOG_CALL_SK, LogCallSchema } from "../../../schema/LogCall";
import crypto from "crypto";
export const LogCallRequestSchema = z.object({ export const LogCallRequestSchema = z.object({
caller: z.string(), caller: z.string(),
}); });
@ -26,8 +28,24 @@ export const LogCallResponseSchema = z.object({
export type LogCallResponse = z.infer<typeof LogCallResponseSchema>; export type LogCallResponse = z.infer<typeof LogCallResponseSchema>;
function getCode() { // hash is 4 digit number based on todays date + phone number caller
return `${Math.floor(Math.random() * 10000)}`.padStart(4, '0'); // cost saving so we don't generate a new OTP for every caller even if its the same caller
function getCode(caller: string) {
const hash = crypto.createHash("sha256");
const today = new Date();
hash.update(today.toLocaleDateString('en-US'));
hash.update(caller);
const hashHex = hash.digest('hex');
// 2. Convert the hexadecimal string to a BigInt
// This is necessary for large hash values that exceed JavaScript's Number limit.
const hashBigInt = BigInt(`0x${hashHex}`);
// 3. Convert the BigInt to a decimal string
const hashDecimal = hashBigInt.toString();
return hashDecimal.substring(hashDecimal.length - 4,);
}; };
export const handler: ServerlessFunctionSignature<TwilioContext, LogCallRequestTwilio> = withMetrics("logCall", async (context, event, callback, metricsRegistry) => { export const handler: ServerlessFunctionSignature<TwilioContext, LogCallRequestTwilio> = withMetrics("logCall", async (context, event, callback, metricsRegistry) => {
@ -46,8 +64,16 @@ export const handler: ServerlessFunctionSignature<TwilioContext, LogCallRequestT
msg: "Onboarding is not open", msg: "Onboarding is not open",
}); });
} else { } else {
// TODO: best efforts cleanup
// console.log("Attempting best efforts cleanup of logged calls")
// const items = await db.entities.logCall.findAll();
// const toRemove = items.filter(item => item.SK === LOG_CALL_SK && !isTTLInFuture(item));
// console.log(`There are ${toRemove.length} old call logs to remove`);
// await db.entities.logCall.deleteBatch(toRemove);
// console.log("done cleaning up logged calls");
// log this caller // log this caller
const otp = getCode(); const otp = getCode(caller);
const logCall = LogCallSchema.parse({ const logCall = LogCallSchema.parse({
PK: otp, PK: otp,
SK: LOG_CALL_SK, SK: LOG_CALL_SK,

View File

@ -5,6 +5,7 @@ import { StatusResponse } from "../src/functions/api/door/status";
import { sleep } from "bun"; import { sleep } from "bun";
import { ONBOARDING_DOOR_NAME, ONBOARDING_DOOR_PIN } from "../src/schema/DoorConfig"; import { ONBOARDING_DOOR_NAME, ONBOARDING_DOOR_PIN } from "../src/schema/DoorConfig";
import { LogCallResponse } from "../src/functions/api/door/logCall"; import { LogCallResponse } from "../src/functions/api/door/logCall";
import { CheckOtpResponse } from "../src/functions/api/door/checkOtp";
// these tests should only run locally // these tests should only run locally
if (process.env.STAGE === 'staging') { if (process.env.STAGE === 'staging') {
@ -109,7 +110,15 @@ describe("call log path works", () => {
expect(logCallRes.status).toBe(200); expect(logCallRes.status).toBe(200);
const otp = (await logCallRes.json() as LogCallResponse).otp const otp = (await logCallRes.json() as LogCallResponse).otp
expect(otp.length).toBe(4) expect(otp.length).toBe(4);
const checkOtpRes = await fetch(baseUrl + `/api/door/checkOtp?otp=${otp}`);
expect(checkOtpRes.status).toBe(200);
// check OTP
const caller = (await checkOtpRes.json() as CheckOtpResponse).buzzer;
expect(caller).toBe(buzzerNumber);
}); });
test("call log after door closed, should not return OTP", async () => { test("call log after door closed, should not return OTP", async () => {

View File

@ -27,4 +27,16 @@ This will simulate a buzzer calling from 6133163433 (the test buzzer)
If you let it poll for 10s then it should respond with Twilio xml saying to dial fallback numbers. If you let it poll for 10s then it should respond with Twilio xml saying to dial fallback numbers.
If doorman-api is not running, it would always return with a `<Reject/>` If doorman-api is not running, it would always return with a `<Reject/>`
## adding new audio assets
Generate using this website: https://ttsmp3.com/
select US English / Salli for consistency with the voice.
After generated, go to this website: https://www.mp3louder.com/
boost the audio 3db and save it
put it in the doorman-client/assets folder with suffix `.protected.mp3`

View File

@ -5,9 +5,12 @@ export interface ICountdownBarProps {
timeSeconds: number; timeSeconds: number;
onCancel?: () => void; onCancel?: () => void;
onExpiry?: () => void; onExpiry?: () => void;
label?: string;
description?: string;
additionalInfo?: string;
} }
export const CountdownBar = ({ timeSeconds, onCancel, onExpiry }: ICountdownBarProps) => { export const CountdownBar = ({ timeSeconds, onCancel, onExpiry, label, description, additionalInfo }: ICountdownBarProps) => {
const [ countdown, setCountdown ] = useState<number>(timeSeconds); const [ countdown, setCountdown ] = useState<number>(timeSeconds);
useEffect(() => { useEffect(() => {
@ -26,16 +29,13 @@ export const CountdownBar = ({ timeSeconds, onCancel, onExpiry }: ICountdownBarP
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [countdown]); }, [countdown]);
return ( return (
<ProgressBar <ProgressBar
label={"Authentication Timeout"} label={label || "Authentication Timeout"}
description={"Dial the buzzer before the time expires"} description={description || "Dial the buzzer before the time expires"}
additionalInfo={`Authentication expires after ${timeSeconds}s to keep the door secure. Please dial the buzzer within the time remaining or it won't unlock`} additionalInfo={additionalInfo || `Authentication expires after ${timeSeconds}s to keep the door secure. Please dial the buzzer within the time remaining or it won't unlock`}
value={(countdown / timeSeconds) * 100} value={(countdown / timeSeconds) * 100}
variant="flash" variant="flash"
/> />
); );
}
}

View File

@ -7,6 +7,7 @@ import { ErrorPage } from './pages/ErrorPage';
import { QueryRouter } from './routers/QueryRouter'; import { QueryRouter } from './routers/QueryRouter';
import { EditPage } from './pages/EditPage'; import { EditPage } from './pages/EditPage';
import { RedirectPage } from './pages/RedirectPage'; import { RedirectPage } from './pages/RedirectPage';
import { OnboardingPage } from './pages/OnboardingPage';
const root = ReactDOM.createRoot( const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement document.getElementById('root') as HTMLElement
@ -26,7 +27,8 @@ const router = createBrowserRouter([
mapping={{ mapping={{
edit: <EditPage />, edit: <EditPage />,
door: <DoorPage />, door: <DoorPage />,
onboard: <EditPage isOnboarding={true} />, onboard: <OnboardingPage />,
onboardForm: <EditPage isOnboarding={true} />,
state: <RedirectPage /> state: <RedirectPage />
}} }}
/> />

View File

@ -20,9 +20,14 @@ export interface EditPageProps {
export const EditPage = ({ isOnboarding }: EditPageProps) => { export const EditPage = ({ isOnboarding }: EditPageProps) => {
const doorResponse = useLoaderData() as DoorResponse; const doorResponse = useLoaderData() as DoorResponse;
const door = doorResponse.id; const door = doorResponse.id;
const [params, setParams] = useSearchParams();
const buzzer = params.get("buzzer") || undefined;
const { setValue, setError, getValues, watch, formState, clearErrors, control, register } = useForm<DoorEditForm>({ const { setValue, setError, getValues, watch, formState, clearErrors, control, register } = useForm<DoorEditForm>({
defaultValues: { defaultValues: {
...doorResponse, ...doorResponse,
buzzer,
isConfirmed: !isOnboarding, isConfirmed: !isOnboarding,
}, },
mode: "all", mode: "all",
@ -62,7 +67,7 @@ export const EditPage = ({ isOnboarding }: EditPageProps) => {
const fallbackNumbersError = formState.errors.fallbackNumbers?.message; const fallbackNumbersError = formState.errors.fallbackNumbers?.message;
const discordUsersError = formState.errors.discordUsers?.message; const discordUsersError = formState.errors.discordUsers?.message;
const backUrl = isOnboarding? '/': `?door=${door}`; const backUrl = isOnboarding? '/?onboard': `?door=${door}`;
const apiRoute = isOnboarding ? ONBOARD_ROUTE: EDIT_ROUTE; const apiRoute = isOnboarding ? ONBOARD_ROUTE: EDIT_ROUTE;
return ( return (
@ -74,7 +79,7 @@ export const EditPage = ({ isOnboarding }: EditPageProps) => {
<BreadcrumbGroup <BreadcrumbGroup
items={[ items={[
{ text: 'Door', href: backUrl }, { text: 'Door', href: backUrl },
isOnboarding ? { text: "Onboard", href: "?onboard" }: { text: door, href: `?edit&door=${door}` }, isOnboarding ? { text: "Onboard Form", href: `?onboardForm&buzzer=${buzzer}` }: { text: door, href: `?edit&door=${door}` },
]} ]}
/> />
} }
@ -146,11 +151,19 @@ export const EditPage = ({ isOnboarding }: EditPageProps) => {
> >
<Container> <Container>
<SpaceBetween direction="vertical" size="l"> <SpaceBetween direction="vertical" size="l">
{isOnboarding &&
<FormField label="Register Doorman in Building" constraintText="Confirm that you added our number to your buzzer system: 604-757-1824" errorText={formState.errors.isConfirmed?.message}>
<label>
I have added Doorman's number to my buzzer
<input type="checkbox" {...register("isConfirmed")} />
</label>
</FormField>
}
<FormField label="Door Name" constraintText="Unique name for this Door, will be used in your Doorman URL"> <FormField label="Door Name" constraintText="Unique name for this Door, will be used in your Doorman URL">
<CInput readOnly={!isOnboarding} disabled={!isOnboarding} name="id" control={control} /> <CInput readOnly={!isOnboarding} disabled={!isOnboarding} name="id" control={control} />
</FormField> </FormField>
<FormField label="Buzzer Number" constraintText="Phone number that calls you when your buzzer is called"> <FormField label="Buzzer Number" constraintText="Phone number that calls you when your buzzer is called">
<CInput readOnly={!isOnboarding} disabled={!isOnboarding} name="buzzer" control={control} /> <CInput readOnly={!!buzzer} disabled={!!buzzer} name="buzzer" control={control} />
</FormField> </FormField>
<FormField label="PIN" constraintText={"Code to unlock this Door in Doorman"}> <FormField label="PIN" constraintText={"Code to unlock this Door in Doorman"}>
<CInput type="password" name="pin" control={control} /> <CInput type="password" name="pin" control={control} />
@ -233,14 +246,6 @@ export const EditPage = ({ isOnboarding }: EditPageProps) => {
<FormField label="Welcome Message" constraintText="Message to display after a successful unlock"> <FormField label="Welcome Message" constraintText="Message to display after a successful unlock">
<CTextArea name="greeting" control={control} /> <CTextArea name="greeting" control={control} />
</FormField> </FormField>
{isOnboarding &&
<FormField label="Register Doorman in Building" constraintText="Confirm that you added our number to your buzzer system: 604-757-1824" errorText={formState.errors.isConfirmed?.message}>
<label>
I have added Doorman's number to my buzzer
<input type="checkbox" {...register("isConfirmed")} />
</label>
</FormField>
}
</SpaceBetween> </SpaceBetween>
</Container> </Container>
</Form> </Form>

View File

@ -0,0 +1,243 @@
import { ReactNode, useEffect, useState } from "react";
import { useLoaderData, useNavigate, useSearchParams } from "react-router-dom";
import { AppLayout, BreadcrumbGroup, Container, Flashbar, FlashbarProps, Header, SpaceBetween, TextContent, Wizard } from "@cloudscape-design/components";
import OtpInput from 'react-otp-input';
import { CountdownBar } from "../components/CountdownBar";
import { fetchUrlEncoded } from "../helpers/FetchHelper";
import { readSync } from "fs";
export function OnboardingPage() {
const navigate = useNavigate();
const [ step, setStep ] = useState(0);
const [ alerts, setAlerts ] = useState<FlashbarProps.MessageDefinition[]>([]);
const [ otp, setOtp ] = useState("");
const [ buzzerNumber, setBuzzerNumber ] = useState("");
const dismissAlert = (id: string) => {
setAlerts(alerts => alerts.filter(alert => alert.id !== id));
}
const dismissInProgressAlerts = () => {
setAlerts(alerts => alerts.filter(alert => alert.type !== 'in-progress'));
}
const createAlert = (type: FlashbarProps.Type, content: ReactNode): FlashbarProps.MessageDefinition => {
const id = `${Math.random()}`;
return {
id,
type,
content,
dismissible: type !== 'in-progress',
onDismiss: () => dismissAlert(id),
}
}
const addAlert = (type: FlashbarProps.Type, content: ReactNode): string => {
const newAlert = createAlert(type, content);
setAlerts(alerts => [
newAlert,
...alerts,
]);
return newAlert.id as string;
}
useEffect(() => {
if (otp.length === 4) {
// see if this is valid otp
fetchUrlEncoded('/api/door/checkOtp', {
otp,
}).then(res => res.json())
.then(res => {
if (res.msg) {
addAlert("error", res.msg);
return;
}
const apiNumber = res.buzzer;
setBuzzerNumber(apiNumber);
addAlert("success", `Successfully verified Buzzer as ${apiNumber}`);
setTimeout(() => {
setStep(3);
}, 1_000);
});
}
}, [otp]);
return (
<AppLayout
contentType="wizard"
navigationHide
toolsHide
breadcrumbs={
<BreadcrumbGroup
items={[
{ text: 'Door', href: '#' },
{ text: 'Onboarding', href: '#' },
]}
/>
}
notifications={
<Flashbar
stackItems={true}
items={alerts}
/>
}
content={
<Wizard
i18nStrings={{
stepNumberLabel: stepNumber =>
`Step ${stepNumber}`,
collapsedStepsLabel: (stepNumber, stepsCount) =>
`Step ${stepNumber} of ${stepsCount}`,
skipToButtonLabel: (step, stepNumber) =>
`Skip to ${step.title}`,
navigationAriaLabel: "Steps",
cancelButton: "Cancel",
previousButton: "Previous",
nextButton: "Next",
submitButton: "Finish",
optional: "optional"
}}
activeStepIndex={step}
onSubmit={() => {
navigate(`/?onboardForm&buzzer=${buzzerNumber}`)
}}
onNavigate={({ detail }) => {
if (detail.requestedStepIndex < step) {
dismissInProgressAlerts();
setStep(detail.requestedStepIndex);
return;
}
if (detail.requestedStepIndex === 2) {
fetchUrlEncoded('/api/door/auth', {
door: "onboardingflag",
key: 1234
})
.then(res => res.json())
.then(res => {
if (res.status === "CLOSED" || !res.msg) {
addAlert('error', 'Something went wrong, please try again');
} else {
addAlert("in-progress", (
<CountdownBar
label="Onboarding Timeout"
description="Complete Onboarding before timeout"
additionalInfo="Onboarding times out after 15 minutes, please complete the onboarding before then"
timeSeconds={15 * 60}
onExpiry={() => {
addAlert("error", "Onboarding timed out: please try again");
setStep(1);
}}
/>
));
setStep(detail.requestedStepIndex);
}
});
return;
}
if (detail.requestedStepIndex === 3) {
if (buzzerNumber === "") {
addAlert('error', "Please enter 4 digit verification code to proceed");
return;
}
}
setStep(detail.requestedStepIndex);
}}
onCancel={() => {
setStep(0);
dismissInProgressAlerts();
}}
steps={[
{
title: "Set your Buzzer Number",
description:"Before onboarding Doorman, you need to configure your Buzzer number",
content: (
<Container
header={
<Header variant="h2">
Set your Buzzer Number
</Header>
}
>
This would usually involve contacting your building to change your Buzzer to call Doorman's phone number:
<Header variant="h3">604-757-1824</Header>
Once you have done this, continue along the onboarding process!
</Container>
)
},
{
title: "Get to the Buzzer",
description:"To continue onboarding this Door, you should go physically to the Buzzer",
content: (
<Container
header={
<Header variant="h2">
Get to the buzzer
</Header>
}
>
The next step will require you to dial your Buzzer so Doorman can identify who is calling. Once you arrive at your Buzzer, advance to the next step.
</Container>
)
},
{
title: "Identify",
description: "Enter the 4 digit code that you hear back from Doorman",
content: (
<Container
header={
<Header variant="h2">
Identify
</Header>
}
>
For this step, you need to dial your Buzzer. It should call Doorman, and you will hear a message with a generated 4 digit code.
<br/>
Enter the 4 digit code below to proceed
<OtpInput
containerStyle={{
width: "100%",
justifyContent: "center",
}}
inputStyle={{
margin: '0.5rem',
height: '3rem',
width: '2rem',
fontSize: '2rem',
}}
value={otp}
numInputs={4}
onChange={(e) => setOtp(e)}
renderInput={(props) => <input {...props} />}
/>
</Container>
)
},
{
title: "Complete Onboarding Form",
description: "Redirect to complete the rest of onboarding form",
content: (
<Container
header={
<Header variant="h2">
Complete Onboarding Form
</Header>
}
>
Your buzzer number was identified as:
<Header>{buzzerNumber}</Header>
Click Finish below to be redirected to complete the rest of the onboarding for Doorman
</Container>
)
},
]}
/>
}
/>
);
};