Skip to content

Commit

Permalink
Basic real-time implementation
Browse files Browse the repository at this point in the history
  • Loading branch information
paescuj committed Jul 31, 2023
1 parent 032abf5 commit 0367010
Show file tree
Hide file tree
Showing 8 changed files with 207 additions and 76 deletions.
1 change: 1 addition & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ services:
DB_USER: directus
ADMIN_EMAIL: admin@${PUBLIC_DOMAIN}
CORS_ENABLED: 'true'
WEBSOCKETS_ENABLED: 'true'

chatwoot-db:
image: postgres:15-alpine
Expand Down
9 changes: 1 addition & 8 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions web/.env.development
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
NEXT_PUBLIC_DOMAIN=$PUBLIC_DOMAIN
NEXT_PUBLIC_API_URL=http://localhost:8055
NEXT_PUBLIC_WS_URL=ws://localhost:8055/websocket
NEXT_PUBLIC_CHATWOOT_URL=http://localhost:3001
1 change: 1 addition & 0 deletions web/.env.production
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
NEXT_PUBLIC_DOMAIN=$PUBLIC_DOMAIN
NEXT_PUBLIC_API_URL=https://directus.$PUBLIC_DOMAIN
NEXT_PUBLIC_WS_URL=wss://directus.$PUBLIC_DOMAIN/websocket
NEXT_PUBLIC_CHATWOOT_URL=https://chatwoot.$PUBLIC_DOMAIN
139 changes: 132 additions & 7 deletions web/lib/directus.js
Original file line number Diff line number Diff line change
@@ -1,29 +1,154 @@
import env from '@beam-australia/react-env';
import { Directus } from '@directus/sdk';

export const url = env('API_URL') || 'http://localhost:8055';
import locales from '@/locales';
import { AuthStore } from '@/stores/AuthStore';
import { LocaleStore } from '@/stores/LocaleStore';

export const url = env('API_URL');
export const directus = new Directus(url, { storage: { prefix: 'jaa_' } });

/**
* Get current user from API.
* @returns User object if session is valid, otherwise 'false'.
* @returns User object if session is valid.
*/
export async function getUser() {
try {
// Fetch and return user (also to check if session is valid)
return await directus.users.me.read();
} catch {
// Something is wrong
return false;
return;
}
}

/** Get current auth token. */
export const getBearer = async () => {
/**
* Get current access token.
* @returns Access token.
*/
export async function getToken() {
try {
await directus.auth.refreshIfExpired();
} catch {
// Ignore error
}
return `Bearer ${await directus.auth.token}`;
};
return directus.auth.token;
}

export async function getBearer() {
return `Bearer ${await getToken()}`;
}

/**
* Login to the API.
*/
export async function login(email, password) {
await directus.auth.login({ email, password });

// Update stores
const user = await directus.users.me.read();
AuthStore.update((s) => {
s.user = user;
});
const language = user.language?.split(/[-_]/)[0];
if (language in locales) {
LocaleStore.update((s) => {
s.locale = language;
});
}
}

let websocket;
let websocketReadyResolver;
let websocketReady;
const websocketSubscriptions = new Map();

async function receiveMessage(message) {
const data = JSON.parse(message.data);

if (data.type === 'ping') {
websocket.send(
JSON.stringify({
type: 'pong',
}),
);
return;
}

if (data.type === 'auth' && data.status === 'ok') {
websocketReadyResolver(websocket);
return;
}

if (
data.type === 'auth' &&
data.status === 'error' &&
data.error.code === 'TOKEN_EXPIRED'
) {
websocketReady = new Promise((r) => {
websocketReadyResolver = r;
});
websocket.send(
JSON.stringify({
type: 'auth',
access_token: await getToken(),
}),
);
return;
}

if (data.type === 'subscription' && data.uid && data.event !== 'init') {
const callback = websocketSubscriptions.get(data.uid);
callback?.(data.data);
}
}

export async function getWebsocket() {
if (!websocket) {
websocket = new WebSocket(env('WS_URL'));
websocket.addEventListener('open', async () =>
websocket.send(
JSON.stringify({
type: 'auth',
access_token: await getToken(),
}),
),
);
websocketReady = new Promise((r) => {
websocketReadyResolver = r;
});
websocket.addEventListener('message', (message) => receiveMessage(message));
}
return websocketReady;
}

export async function subscribe(uid, options, callback) {
const websocket = await getWebsocket();

if (websocketSubscriptions.has(uid)) {
return;
}

websocketSubscriptions.set(uid, callback);

websocket.send(
JSON.stringify({
uid,
type: 'subscribe',
collection: options.collection,
event: options.event,
query: options.query,
}),
);
}

export function unsubscribe(uid) {
websocket.send(
JSON.stringify({
uid,
type: 'unsubscribe',
}),
);

websocketSubscriptions.delete(uid);
}
1 change: 0 additions & 1 deletion web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,6 @@
"framer-motion": "10.15.0",
"iconoir-react": "6.10.0",
"language-icons": "0.3.0",
"lodash.isequal": "4.5.0",
"next": "13.4.12",
"object-path": "0.11.8",
"object-traversal": "1.0.1",
Expand Down
70 changes: 50 additions & 20 deletions web/pages/admin.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import {
Tabs,
Text,
} from '@chakra-ui/react';
import isEqual from 'lodash.isequal';
import dynamic from 'next/dynamic';
import Head from 'next/head';
import NextLink from 'next/link';
Expand All @@ -22,7 +21,7 @@ import Header from '@/components/common/Header';
import Layout from '@/components/common/Layout';
import Loader from '@/components/common/Loader';
import Logo from '@/components/common/Logo';
import { directus } from '@/lib/directus';
import { directus, subscribe, unsubscribe } from '@/lib/directus';
import { AuthStore } from '@/stores/AuthStore';

const Jobs = dynamic(() => import('@/components/admin/Jobs'));
Expand Down Expand Up @@ -116,29 +115,60 @@ export default function Admin() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [user]);

// Fetch jobs all 5 seconds
// TODO: Replace with better solution, e.g. websockets
// Subscribe to updates
useEffect(() => {
if (!loading.state) {
const timer = setInterval(async () => {
try {
const jobs = await directus
.items('jobs')
.readByQuery({ fields: ['*', 'feedback.*'] });
setJobs((prevState) => {
if (isEqual(prevState, jobs.data)) {
return prevState;
} else {
return jobs.data;
subscribe(
'admin-feedback-create',
{ collection: 'feedback', event: 'create' },
([feedback]) =>
setJobs((prevJobs) => {
const jobs = [...prevJobs];
const job = jobs.find((job) => job.id === feedback.job);
job.feedback = [...job.feedback, feedback];
return jobs;
}),
);

subscribe(
'admin-dates-create',
{ collection: 'dates', event: 'create' },
(date) =>
setDates((prevDates) => {
return [...prevDates, ...date];
}),
);

subscribe(
'admin-dates-update',
{ collection: 'dates', event: 'update' },
(updatedDates) =>
setDates((prevDates) => {
const dates = [...prevDates];
for (const updatedDate of updatedDates) {
const index = dates.findIndex(
(date) => date.id === updatedDate.id,
);
dates[index] = updatedDate;
}
});
} catch {
// Ignore error
}
}, 5000);
return dates;
}),
);

subscribe(
'admin-dates-delete',
{ collection: 'dates', event: 'delete' },
(deletedDates) =>
setDates((prevDates) => {
return prevDates.filter((date) => !deletedDates.includes(date.id));
}),
);

return () => {
clearInterval(timer);
unsubscribe('admin-feedback-create');
unsubscribe('admin-dates-create');
unsubscribe('admin-dates-update');
unsubscribe('admin-dates-delete');
};
}
}, [loading]);
Expand Down
61 changes: 21 additions & 40 deletions web/pages/login.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,8 @@ import { useIntl } from 'react-intl';

import Layout from '@/components/common/Layout';
import Loader from '@/components/common/Loader';
import { directus } from '@/lib/directus';
import locales from '@/locales';
import { login } from '@/lib/directus';
import { AuthStore } from '@/stores/AuthStore';
import { LocaleStore } from '@/stores/LocaleStore';

export default function Login() {
const [loading, setLoading] = useState(true);
Expand Down Expand Up @@ -79,45 +77,28 @@ export default function Login() {
// Try to login with submitted code
async function onSubmit({ code }) {
const { company, job, admin } = router.query;
// Use configured domain from env (with fallback of current domain) for users email address
const host = env('DOMAIN') || window.location.host;
// Use configured domain from env for users email address
const host = env('DOMAIN');

await directus.auth
.login({
email: admin ? `admin@${host}` : `${company}-${job}@${host}`,
password: code,
})
.then(async () => {
// Update stores
const user = await directus.users.me.read();
AuthStore.update((s) => {
s.user = user;
});
// Redirect
await router.push({
pathname: admin ? '/admin' : '/application',
});

const language = user.language?.split(/[-_]/)[0];
if (language in locales) {
LocaleStore.update((s) => {
s.locale = language;
});
}
})
.catch((error) => {
const messages = {
'Network Error': formatMessage({ id: 'no_connection' }),
'Invalid user credentials.': formatMessage({
id: 'access_code_or_url_invalid',
}),
default: formatMessage({ id: 'unknown_error' }),
};
setError('code', {
type: 'manual',
message: messages[error.message] || messages['default'],
});
try {
await login(admin ? `admin@${host}` : `${company}-${job}@${host}`, code);
// Redirect
await router.push({
pathname: admin ? '/admin' : '/application',
});
} catch (error) {
const messages = {
'Network Error': formatMessage({ id: 'no_connection' }),
'Invalid user credentials.': formatMessage({
id: 'access_code_or_url_invalid',
}),
default: formatMessage({ id: 'unknown_error' }),
};
setError('code', {
type: 'manual',
message: messages[error.message] || messages['default'],
});
}
}

return (
Expand Down

0 comments on commit 0367010

Please sign in to comment.