Skip to content

Commit

Permalink
Add availability
Browse files Browse the repository at this point in the history
  • Loading branch information
domdomegg committed Apr 21, 2024
1 parent 411c87e commit 551ca42
Show file tree
Hide file tree
Showing 36 changed files with 1,849 additions and 31 deletions.
9 changes: 9 additions & 0 deletions apps/availability/.env.local.template
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Airtable: https://support.airtable.com/docs/creating-and-using-api-keys-and-access-tokens
AIRTABLE_PERSONAL_ACCESS_TOKEN=

# BlueDot Impact Slack, #tech-dev-alerts
ALERTS_SLACK_CHANNEL_ID=C04SFUECECU
# For (local) BlueBot see https://airtable.com/appnNmNoNMB6crg6I/tbllthJ2YSPDsKWCt/viwfjWLvplq6Xw93D/recqurdJTPWzm5y4W
# For (prod) BlueBot see https://api.slack.com/apps/A04PV2GAQRY/install-on-team
# Starts 'xoxb-'
ALERTS_SLACK_BOT_TOKEN=
6 changes: 6 additions & 0 deletions apps/availability/.env.test
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Airtable
AIRTABLE_PERSONAL_ACCESS_TOKEN=FAKE_TOKEN

# Slack
ALERTS_SLACK_CHANNEL_ID=C04SFUECECU
ALERTS_SLACK_BOT_TOKEN=FAKE_TOKEN
25 changes: 25 additions & 0 deletions apps/availability/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
FROM node:lts-alpine@sha256:7e227295e96f5b00aa79555ae166f50610940d888fc2e321cf36304cbd17d7d6 AS base

RUN apk update && apk add --no-cache dumb-init

WORKDIR /app

ENV NODE_ENV production
ENV NEXT_TELEMETRY_DISABLED 1

# Set the correct permission for prerender cache
RUN mkdir .next
RUN chown node:node .next

ARG APP_NAME
ENV APP_NAME=${APP_NAME}

COPY --chown=node:node .next/standalone ./
COPY --chown=node:node public ./apps/${APP_NAME}/public
COPY --chown=node:node .next/static ./apps/${APP_NAME}/.next/static

USER node

EXPOSE 8080

CMD HOSTNAME="0.0.0.0" PORT="8080" dumb-init node ./apps/${APP_NAME}/server.js
5 changes: 5 additions & 0 deletions apps/availability/next-env.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />

// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.
41 changes: 41 additions & 0 deletions apps/availability/next.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/** @type {import('next').NextConfig} */
module.exports = {
transpilePackages: ['@bluedot/ui'],
reactStrictMode: true,
output: 'standalone',

// We already run eslint as a separate step
eslint: {
ignoreDuringBuilds: true,
},

poweredByHeader: false,
headers: async () => {
return [
// See https://developers.zoom.us/docs/meeting-sdk/web/sharedarraybuffer/
{
source: '/',
headers: [
{
key: 'Cross-Origin-Embedder-Policy',
value: 'require-corp',
},
{
key: 'Cross-Origin-Opener-Policy',
value: 'same-origin',
},
],
},
{
source: '/:path*',
headers: [
{
key: 'X-Bluedot-Version',
// eslint-disable-next-line turbo/no-undeclared-env-vars
value: process.env.VERSION_TAG || 'unknown',
},
],
},
];
},
};
46 changes: 46 additions & 0 deletions apps/availability/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
{
"name": "@bluedot/availability",
"version": "1.0.0",
"private": true,
"scripts": {
"postinstall": "shx cp -n .env.local.template .env.local",
"start": "next dev -p 8000",
"build": "next build",
"lint": "eslint .",
"test": "vitest --run",
"test:watch": "vitest",
"deploy:prod": "tools/deployDocker.sh"
},
"dependencies": {
"@bluedot/ui": "*",
"airtable-ts": "^1.0.0",
"axios": "^1.6.8",
"clsx": "^2.1.0",
"http-errors": "^2.0.0",
"next": "^14.0.3",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hook-form": "^7.51.3",
"react-select": "^5.8.0",
"weekly-availabilities": "^1.0.0"
},
"devDependencies": {
"@bluedot/eslint-config": "*",
"@bluedot/typescript-config": "*",
"@testing-library/react": "^15.0.2",
"@types/node": "^20.10.3",
"@types/react": "^18.0.22",
"@types/react-dom": "^18.0.7",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.19",
"dotenv": "^16.4.5",
"eslint": "^8.53.0",
"happy-dom": "^14.7.1",
"node-mocks-http": "^1.14.1",
"postcss": "^8.4.38",
"shx": "^0.3.4",
"tailwindcss": "^3.4.3",
"typescript": "^5.3.2",
"vitest": "^1.5.0"
}
}
6 changes: 6 additions & 0 deletions apps/availability/postcss.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
Binary file not shown.
Binary file not shown.
Binary file added apps/availability/public/fonts/Roobert-Bold.woff2
Binary file not shown.
Binary file not shown.
Binary file not shown.
24 changes: 24 additions & 0 deletions apps/availability/src/components/SpinnerIcon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
export const SpinnerIcon: React.FC = () => {
return (
<svg
className="animate-spin h-5 w-5 text-gray-700"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
>
<circle
className="opacity-25"
cx="12"
cy="12"
r="10"
stroke="currentColor"
strokeWidth="4"
/>
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
);
};
75 changes: 75 additions & 0 deletions apps/availability/src/globals.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
@tailwind base;
@tailwind components;
@tailwind utilities;

@font-face {
font-family: "Reckless Neue";
src: url("/fonts/RecklessNeue-Light.woff2") format("woff2");
font-weight: 300;
font-style: normal;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
font-style: normal
}

@font-face {
font-family: "Reckless Neue";
src: url("/fonts/RecklessNeue-LightItalic.woff2") format("woff2");
font-weight: 300;
font-style: normal;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
font-style: italic
}

@font-face {
font-family: "Roobert";
src: url("/fonts/Roobert-Light.woff2") format("woff2");
font-weight: 300;
font-style: normal;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
font-style: normal
}

@font-face {
font-family: "Roobert";
src: url("/fonts/Roobert-Regular.woff2") format("woff2");
font-weight: 400;
font-style: normal;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
font-style: normal
}

@font-face {
font-family: "Roobert";
src: url("/fonts/Roobert-Bold.woff2") format("woff2");
font-weight: 700;
font-style: normal;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
font-style: normal
}

/* TODO: move these styles out of here */

.setup input {
@apply px-3 py-2 text-gray-700 rounded border focus:outline focus:outline-gray-400;
}

.setup button {
@apply px-3 py-1 rounded text-white bg-blue-500 hover:bg-blue-600 disabled:bg-blue-300;
}

.timezone-select.solid-select-container {
@apply w-32;
}

.timezone-select .solid-select-list {
@apply bg-white border-gray-300 border-2 w-32;
}

.timezone-select .solid-select-option {
@apply flex justify-center cursor-pointer;
}
37 changes: 37 additions & 0 deletions apps/availability/src/lib/api/apiRoute.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import createHttpError from 'http-errors';
import { NextApiHandler } from 'next';
import { slackAlert } from './slackAlert';

export const apiRoute = (handler: NextApiHandler, useAuth: true | 'insecure_no_auth' = true): NextApiHandler => async (req, res) => {
try {
if (useAuth !== 'insecure_no_auth') {
const token = req.headers.authorization?.slice('Bearer '.length).trim();
if (!token) {
throw new createHttpError.Unauthorized('Missing token');
}
throw new Error('Authenticated endpoints not supported');
}
await handler(req, res);
} catch (err: unknown) {
if (createHttpError.isHttpError(err) && err.expose) {
console.warn(`Error handling request on route ${req.method} ${req.url}:`);

Check warning on line 17 in apps/availability/src/lib/api/apiRoute.ts

View workflow job for this annotation

GitHub Actions / ci

Unexpected console statement
console.warn(err);

Check warning on line 18 in apps/availability/src/lib/api/apiRoute.ts

View workflow job for this annotation

GitHub Actions / ci

Unexpected console statement
res.status(err.statusCode).json({ error: err.message });
return;
}

console.error(`Internal error handling request on route ${req.method} ${req.url}:`);

Check warning on line 23 in apps/availability/src/lib/api/apiRoute.ts

View workflow job for this annotation

GitHub Actions / ci

Unexpected console statement
console.error(err);

Check warning on line 24 in apps/availability/src/lib/api/apiRoute.ts

View workflow job for this annotation

GitHub Actions / ci

Unexpected console statement
try {
await slackAlert([
`Error: Failed request on route ${req.method} ${req.url}: ${err instanceof Error ? err.message : String(err)}`,
...(err instanceof Error ? [`Stack:\n\`\`\`${err.stack}\`\`\``] : []),
]);
} catch (slackError) {
console.error('Failed to send Slack', slackError);

Check warning on line 31 in apps/availability/src/lib/api/apiRoute.ts

View workflow job for this annotation

GitHub Actions / ci

Unexpected console statement
}
res.status(createHttpError.isHttpError(err) ? err.statusCode : 500).json({
error: 'Internal Server Error',
});
}
};
25 changes: 25 additions & 0 deletions apps/availability/src/lib/api/db.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { AirtableTs, Table, Item } from 'airtable-ts';
import env from './env';

export default new AirtableTs({
apiKey: env.AIRTABLE_PERSONAL_ACCESS_TOKEN,
});

export interface FormConfiguration extends Item {
'Slug': string,
'Title': string,
'Webhook': string,
'Minimum length': number,
}

export const formConfigurationTable: Table<FormConfiguration> = {
name: 'form configuration',
baseId: 'app6dkBHka8c4WaEj',
tableId: 'tblvsaRl69XV8azGZ',
schema: {
Slug: 'string',
Title: 'string',
Webhook: 'string',
'Minimum length': 'number',
},
};
14 changes: 14 additions & 0 deletions apps/availability/src/lib/api/env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { validateEnv } from '../validateEnv';

const envVars = [
'AIRTABLE_PERSONAL_ACCESS_TOKEN',

'ALERTS_SLACK_CHANNEL_ID',
'ALERTS_SLACK_BOT_TOKEN',
] as const;

export type Env = Record<(typeof envVars)[number], string>;

const env: Env = validateEnv(process.env, envVars);

export default env;
34 changes: 34 additions & 0 deletions apps/availability/src/lib/api/slackAlert.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import axios from 'axios';
import env from './env';

export const slackAlert = async (messages: string[]): Promise<void> => {
if (messages.length === 0) return;
const res = await sendSingleSlackMessage(messages[0]!);
for (let i = 1; i < messages.length; i++) {
// eslint-disable-next-line no-await-in-loop
await sendSingleSlackMessage(messages[i]!, res.ts);
}
};

const sendSingleSlackMessage = async (message: string, threadTs?: string): Promise<{ ts: string }> => {
console.log(`Sending Slack (thread: ${threadTs ?? 'none'}): ${message}`);

Check warning on line 14 in apps/availability/src/lib/api/slackAlert.ts

View workflow job for this annotation

GitHub Actions / ci

Unexpected console statement
return axios({
method: 'post',
baseURL: 'https://slack.com/api/',
url: 'chat.postMessage',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${env.ALERTS_SLACK_BOT_TOKEN}`,
},
data: {
channel: env.ALERTS_SLACK_CHANNEL_ID,
text: `time-availability-form: ${message}`,
thread_ts: threadTs,
},
}).then((res) => {
if (!res.data.ok) {
throw new Error(`Error from Slack API: ${res.data.error}`);
}
return { ts: res.data.ts };
});
};
19 changes: 19 additions & 0 deletions apps/availability/src/lib/date.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { describe, expect, test } from 'vitest';
import { offsets, parseOffsetFromStringToMinutes } from './date';

describe('parseOffsetFromStringToMinutes', () => {
test.each([
['UTC00:00', 0],
['UTC-01:00', 60],
['UTC-01:30', 90],
['UTC+01:00', -60],
['UTC+02:00', -120],
['UTC+03:45', -225],
])('%s', (timezone, offset) => {
expect(parseOffsetFromStringToMinutes(timezone)).toBe(offset);
});

test('all offsets can be parsed', () => {
offsets.forEach((offset) => expect(() => parseOffsetFromStringToMinutes(offset)).not.toThrow());
});
});
Loading

0 comments on commit 551ca42

Please sign in to comment.