diff --git a/client/package-lock.json b/client/package-lock.json
index 5347991..85564d7 100644
--- a/client/package-lock.json
+++ b/client/package-lock.json
@@ -11,7 +11,8 @@
"chota": "^0.9.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
- "react-router-dom": "^6.10.0"
+ "react-router-dom": "^6.10.0",
+ "socket.io-client": "^4.6.1"
},
"devDependencies": {
"@types/react": "^18.0.28",
@@ -784,6 +785,11 @@
"node": ">=14"
}
},
+ "node_modules/@socket.io/component-emitter": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.0.tgz",
+ "integrity": "sha512-+9jVqKhRSpsc591z5vX+X5Yyw+he/HCB4iQ/RYxw35CEPaY1gnsNE43nf9n9AaYjAQrTiI/mOwKUKdUs9vf7Xg=="
+ },
"node_modules/@types/prop-types": {
"version": "15.7.5",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.5.tgz",
@@ -945,7 +951,6 @@
"version": "4.3.4",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
"integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
- "dev": true,
"dependencies": {
"ms": "2.1.2"
},
@@ -964,6 +969,26 @@
"integrity": "sha512-gM7TdwuG3amns/1rlgxMbeeyNoBFPa+4Uu0c7FeROWh4qWmvSOnvcslKmWy51ggLKZ2n/F/4i2HJ+PVNxH9uCQ==",
"dev": true
},
+ "node_modules/engine.io-client": {
+ "version": "6.4.0",
+ "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.4.0.tgz",
+ "integrity": "sha512-GyKPDyoEha+XZ7iEqam49vz6auPnNJ9ZBfy89f+rMMas8AuiMWOZ9PVzu8xb9ZC6rafUqiGHSCfu22ih66E+1g==",
+ "dependencies": {
+ "@socket.io/component-emitter": "~3.1.0",
+ "debug": "~4.3.1",
+ "engine.io-parser": "~5.0.3",
+ "ws": "~8.11.0",
+ "xmlhttprequest-ssl": "~2.0.0"
+ }
+ },
+ "node_modules/engine.io-parser": {
+ "version": "5.0.6",
+ "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.0.6.tgz",
+ "integrity": "sha512-tjuoZDMAdEhVnSFleYPCtdL2GXwVTGtNjoeJd9IhIG3C1xs9uwxqRNEu5WpnDZCaozwVlK/nuQhpodhXSIMaxw==",
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
"node_modules/esbuild": {
"version": "0.17.15",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.17.15.tgz",
@@ -1154,8 +1179,7 @@
"node_modules/ms": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
- "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
- "dev": true
+ "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
},
"node_modules/nanoid": {
"version": "3.3.6",
@@ -1329,6 +1353,32 @@
"semver": "bin/semver.js"
}
},
+ "node_modules/socket.io-client": {
+ "version": "4.6.1",
+ "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.6.1.tgz",
+ "integrity": "sha512-5UswCV6hpaRsNg5kkEHVcbBIXEYoVbMQaHJBXJCyEQ+CiFPV1NIOY0XOFWG4XR4GZcB8Kn6AsRs/9cy9TbqVMQ==",
+ "dependencies": {
+ "@socket.io/component-emitter": "~3.1.0",
+ "debug": "~4.3.2",
+ "engine.io-client": "~6.4.0",
+ "socket.io-parser": "~4.2.1"
+ },
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
+ "node_modules/socket.io-parser": {
+ "version": "4.2.2",
+ "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.2.tgz",
+ "integrity": "sha512-DJtziuKypFkMMHCm2uIshOYC7QaylbtzQwiMYDuCKy3OPkjLzu4B2vAhTlqipRHHzrI0NJeBAizTK7X+6m1jVw==",
+ "dependencies": {
+ "@socket.io/component-emitter": "~3.1.0",
+ "debug": "~4.3.1"
+ },
+ "engines": {
+ "node": ">=10.0.0"
+ }
+ },
"node_modules/source-map-js": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz",
@@ -1459,6 +1509,34 @@
}
}
},
+ "node_modules/ws": {
+ "version": "8.11.0",
+ "resolved": "https://registry.npmjs.org/ws/-/ws-8.11.0.tgz",
+ "integrity": "sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg==",
+ "engines": {
+ "node": ">=10.0.0"
+ },
+ "peerDependencies": {
+ "bufferutil": "^4.0.1",
+ "utf-8-validate": "^5.0.2"
+ },
+ "peerDependenciesMeta": {
+ "bufferutil": {
+ "optional": true
+ },
+ "utf-8-validate": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/xmlhttprequest-ssl": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.0.0.tgz",
+ "integrity": "sha512-QKxVRxiRACQcVuQEYFsI1hhkrMlrXHPegbbd1yn9UHOmRxY+si12nQYzri3vbzt8VdTTRviqcKxcyllFas5z2A==",
+ "engines": {
+ "node": ">=0.4.0"
+ }
+ },
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
diff --git a/client/package.json b/client/package.json
index 73dd572..8d6f957 100644
--- a/client/package.json
+++ b/client/package.json
@@ -12,7 +12,8 @@
"chota": "^0.9.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
- "react-router-dom": "^6.10.0"
+ "react-router-dom": "^6.10.0",
+ "socket.io-client": "^4.6.1"
},
"devDependencies": {
"@types/react": "^18.0.28",
diff --git a/client/src/components/timerCard.tsx b/client/src/components/timerCard.tsx
new file mode 100644
index 0000000..f37a67a
--- /dev/null
+++ b/client/src/components/timerCard.tsx
@@ -0,0 +1,3 @@
+export default function TimerCard({ timer }) {
+ return
{timer.name}
;
+}
diff --git a/client/src/components/timerHeader.tsx b/client/src/components/timerHeader.tsx
new file mode 100644
index 0000000..618f607
--- /dev/null
+++ b/client/src/components/timerHeader.tsx
@@ -0,0 +1,24 @@
+import { useEffect, useState } from "react";
+import { useAuthContext } from "../context/authContext";
+
+export default function TimerHeader({ onSelect }) {
+ const [friends, setFriends] = useState([]);
+ const { friendName } = useAuthContext();
+
+ useEffect(() => {
+ fetch("/api/auth/friends")
+ .then((r) => r.json())
+ .then((friends) => setFriends(friends));
+ }, []);
+
+ return (
+ <>
+ {friendName}
+ {friends.map((friend) => (
+
+ ))}
+ >
+ );
+}
diff --git a/client/src/hooks/useInitialData.ts b/client/src/hooks/useInitialData.ts
new file mode 100644
index 0000000..1f5de20
--- /dev/null
+++ b/client/src/hooks/useInitialData.ts
@@ -0,0 +1,39 @@
+import { io } from "socket.io-client";
+import { useState, useEffect } from "react";
+
+export interface UseInitialDataProps {
+ namespace: string;
+ initialDataEndpoint: string;
+ query?: any;
+}
+
+export const useInitialData = (props: UseInitialDataProps) => {
+ const [data, setData] = useState();
+ const [query, setQuery] = useState(props?.query);
+ const [socket, setSocket] = useState();
+
+ const refreshData = () =>
+ fetch(props.initialDataEndpoint)
+ .then((r) => r.json())
+ .then((r: T) => {
+ setData(r);
+ });
+
+ useEffect(() => {
+ refreshData();
+
+ const socket = io(props.namespace, {
+ query,
+ transports: ["websocket"],
+ });
+
+ setSocket(socket);
+
+ return () => {
+ socket.close();
+ setSocket(undefined);
+ };
+ }, [query]);
+
+ return { data, refreshData, query, setQuery, socket, setData };
+};
diff --git a/client/src/main.tsx b/client/src/main.tsx
index 50c5a66..341b825 100644
--- a/client/src/main.tsx
+++ b/client/src/main.tsx
@@ -5,9 +5,9 @@ import "chota";
import { AuthProvider } from "./context/authContext";
-import Root from "./routes/root";
import NotFound from "./routes/notFound";
import Login from "./routes/login";
+import Timers from "./routes/timers";
import ProtectedRoute from "./routes/protected.tsx";
const router = createBrowserRouter([
@@ -15,7 +15,7 @@ const router = createBrowserRouter([
path: "/",
element: (
-
+
),
errorElement: ,
@@ -27,9 +27,7 @@ const router = createBrowserRouter([
]);
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
-
-
-
-
-
+
+
+
);
diff --git a/client/src/routes/protected.tsx b/client/src/routes/protected.tsx
index a0f4dbd..4870b94 100644
--- a/client/src/routes/protected.tsx
+++ b/client/src/routes/protected.tsx
@@ -4,7 +4,7 @@ import { useAuthContext } from "../context/authContext";
export default function ProtectedRoute({ children }) {
const { signedIn } = useAuthContext();
- if (!signedIn) return ;
+ // if (!signedIn) return ;
return children;
}
diff --git a/client/src/routes/root.tsx b/client/src/routes/root.tsx
deleted file mode 100644
index fce48d1..0000000
--- a/client/src/routes/root.tsx
+++ /dev/null
@@ -1,10 +0,0 @@
-import { Outlet } from "react-router-dom";
-
-export default function Root() {
- return (
- <>
- Hello
-
- >
- );
-}
diff --git a/client/src/routes/timers.tsx b/client/src/routes/timers.tsx
new file mode 100644
index 0000000..2457de4
--- /dev/null
+++ b/client/src/routes/timers.tsx
@@ -0,0 +1,91 @@
+import { useEffect, useState } from "react";
+import TimerCard from "../components/timerCard";
+import TimerHeader from "../components/timerHeader";
+import { useInitialData } from "../hooks/useInitialData";
+
+export type TimersFilter = {
+ friendId: undefined | number; // when undefined, get all
+};
+
+export type TimersProps = {
+ filter: TimersFilter;
+};
+
+export type Friend = {
+ id: number;
+ name: number;
+};
+
+export type TimerResponse = {
+ id: number;
+ name: string;
+ start: Date;
+ created_by: Friend;
+ referenced_friends: Friend[];
+};
+
+const putSince = (timers) =>
+ timers.map((timer) => ({
+ ...timer,
+ since: "10 seconds ago",
+ }));
+
+const makeInitialDataEndpoint = (filter: TimersFilter) => {
+ let url = "/api/timers";
+ if (filter && typeof filter.friendId !== "undefined")
+ url += `?id=${filter.friendId}`;
+ return url;
+};
+
+const makeQuery = (filter: TimersFilter) => {
+ if (filter && typeof filter.friendId !== "undefined")
+ return { friend: filter.friendId };
+ return {};
+};
+
+export default function Timers({ filter }: TimersProps) {
+ const {
+ data: timers,
+ refreshData: refreshTimers,
+ setData: setTimers,
+ query,
+ setQuery,
+ socket,
+ } = useInitialData({
+ initialDataEndpoint: makeInitialDataEndpoint(filter),
+ namespace: "/events/timers",
+ query: makeQuery(filter),
+ });
+
+ useEffect(() => {
+ socket?.on("refreshed", (newTimer: TimerResponse) => {
+ setTimers((timers) =>
+ timers.map((timer) => {
+ if (timer.id === newTimer.id) return newTimer;
+ return timer;
+ })
+ );
+ });
+
+ socket?.on("created", (newTimer: TimerResponse) => {
+ setTimers((timers) => [...timers, newTimer]);
+ });
+ }, [socket]);
+
+ useEffect(() => {
+ const updateTimersInterval = setInterval(() => {
+ setTimers((timers) => putSince(timers));
+ }, 1_000);
+
+ return () => clearInterval(updateTimersInterval);
+ }, []);
+
+ return (
+ <>
+
+ {timers?.map((timer) => (
+
+ ))}
+ >
+ );
+}
diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml
index 4c872ea..45c2f82 100644
--- a/docker-compose.dev.yml
+++ b/docker-compose.dev.yml
@@ -18,6 +18,9 @@ services:
- 8000:80
networks:
- app
+ depends_on:
+ - server
+ - client
build:
context: ./nginx
dockerfile: Dockerfile.dev
diff --git a/nginx/nginx.dev.conf b/nginx/nginx.dev.conf
index 65620e2..b227c13 100644
--- a/nginx/nginx.dev.conf
+++ b/nginx/nginx.dev.conf
@@ -1,23 +1,37 @@
server {
listen 80;
- location / {
- proxy_pass http://friendsclient:3000;
-
- proxy_http_version 1.1;
- proxy_set_header Upgrade $http_upgrade;
- proxy_set_header Connection "upgrade";
- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
- proxy_set_header Host $host;
- }
-
location /api {
proxy_pass http://friendsserver:4000;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header Host $host;
+
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
+ }
+
+ location /socket.io {
+ proxy_pass http://friendsserver:4000;
+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $host;
+
+ proxy_http_version 1.1;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+
+ }
+
+ location / {
+ proxy_pass http://friendsclient:3000;
+
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header Host $host;
+
+ proxy_http_version 1.1;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
}
}
diff --git a/server/src/auth/auth.controller.ts b/server/src/auth/auth.controller.ts
index 3f5648d..b788636 100644
--- a/server/src/auth/auth.controller.ts
+++ b/server/src/auth/auth.controller.ts
@@ -31,16 +31,22 @@ export class AuthController {
return await this.authService.deleteToken(req.token);
}
+ @UseGuards(AuthGuard)
+ @Get('/friends')
+ public async allFriends() {
+ return await this.authService.allFriends();
+ }
+
@Get('/')
- async makeGodToken(@Query() query: RetrieveFriendDTO) {
- const friend = await this.authService.findFriendByName(query.name);
- if (!friend) throw new NotFoundException('Friend not found with that name');
+ public async makeGodToken(@Query() { name, id }: RetrieveFriendDTO) {
+ const friend = await this.authService.findFriendByNameOrId(name, id);
+ if (!friend) throw new NotFoundException('Friend not found by that query');
return await this.authService.createTokenForFriend(friend);
}
@Post()
- async verifyFriend(
+ public async verifyFriend(
@Res({ passthrough: true }) res,
@Body() { signature }: SignedGodTokenDTO,
) {
@@ -59,6 +65,10 @@ export class AuthController {
);
if (!referencedToken)
throw new NotFoundException('Could not find God Token to sign');
+ if (referencedToken.signed)
+ throw new BadRequestException(
+ 'God Token was already signed - no replay attacks plz',
+ );
const { friend } = referencedToken;
const publicKeyObj = await readKey({ armoredKey: friend.public_key });
diff --git a/server/src/auth/auth.guard.ts b/server/src/auth/auth.guard.ts
index 8b871e4..4849775 100644
--- a/server/src/auth/auth.guard.ts
+++ b/server/src/auth/auth.guard.ts
@@ -13,7 +13,8 @@ export class AuthGuard implements CanActivate {
async canActivate(context: ExecutionContext): Promise {
const req = context.switchToHttp().getRequest();
- if (!req.cookies.god_token) throw new UnauthorizedException('No session');
+ if (req && req.cookies && !req.cookies.god_token)
+ throw new UnauthorizedException('No session');
const token = await this.authService.findGodTokenWithFriend(
req.cookies.god_token,
diff --git a/server/src/auth/auth.service.ts b/server/src/auth/auth.service.ts
index a396169..fb1456c 100644
--- a/server/src/auth/auth.service.ts
+++ b/server/src/auth/auth.service.ts
@@ -1,5 +1,5 @@
import { Injectable } from '@nestjs/common';
-import { Friend } from '@prisma/client';
+import { Friend, Prisma } from '@prisma/client';
import { randomInt } from 'crypto';
import { PrismaService } from '../prisma/prisma.service';
@@ -18,12 +18,31 @@ export class AuthService {
.map(() => words[randomInt(0, words.length)])
.join(' ');
+ static FRIEND_SELECT = {
+ id: true,
+ name: true,
+ } as Prisma.FriendSelect;
+
+ public allFriends() {
+ return this.prismaService.friend.findMany({
+ select: AuthService.FRIEND_SELECT,
+ });
+ }
+
public findFriendByName(name: string) {
return this.prismaService.friend.findUnique({
where: { name },
});
}
+ public findFriendByNameOrId(name: string, id: number) {
+ let where: Prisma.FriendWhereUniqueInput = { name };
+ if (typeof id !== 'undefined') where = { id };
+ return this.prismaService.friend.findUnique({
+ where,
+ });
+ }
+
public createTokenForFriend(friend: Friend) {
return this.prismaService.godToken.create({
data: {
diff --git a/server/src/auth/authServer.adapter.ts b/server/src/auth/authServer.adapter.ts
new file mode 100644
index 0000000..9089536
--- /dev/null
+++ b/server/src/auth/authServer.adapter.ts
@@ -0,0 +1,47 @@
+import { INestApplicationContext } from '@nestjs/common';
+import { IoAdapter } from '@nestjs/platform-socket.io';
+import { AuthService } from './auth.service';
+
+const parseCookie = (str) =>
+ str
+ .split(';')
+ .map((v) => v.split('='))
+ .reduce((acc, v) => {
+ acc[decodeURIComponent(v[0].trim())] = decodeURIComponent(v[1].trim());
+ return acc;
+ }, {});
+
+/* Shamelessly stolen from https://github.com/nestjs/nest/issues/882#issuecomment-632698668 */
+export class AuthenticatedSocketIoAdapter extends IoAdapter {
+ private readonly authService: AuthService;
+ constructor(private app: INestApplicationContext) {
+ super(app);
+ this.authService = this.app.get(AuthService);
+ }
+
+ createIOServer(port: number, options?: any): any {
+ options.allowRequest = async (request, allowFunction) => {
+ let verified = false;
+
+ try {
+ const { god_token } = parseCookie(request.headers.cookie);
+
+ const godToken = await this.authService.findGodTokenWithFriend(
+ god_token,
+ );
+ if (godToken && godToken.expiration.getTime() > new Date().getTime())
+ verified = true;
+ } catch (e) {
+ verified = false;
+ }
+
+ if (verified) {
+ return allowFunction(null, true);
+ }
+
+ return allowFunction('Unauthorized', false);
+ };
+
+ return super.createIOServer(port, options);
+ }
+}
diff --git a/server/src/dto/dtos.ts b/server/src/dto/dtos.ts
index 826fc23..59fdd8b 100644
--- a/server/src/dto/dtos.ts
+++ b/server/src/dto/dtos.ts
@@ -1,4 +1,5 @@
-import { IsNotEmpty } from 'class-validator';
+import { Type } from 'class-transformer';
+import { IsNotEmpty, ValidateIf } from 'class-validator';
export class SignedGodTokenDTO {
@IsNotEmpty()
@@ -6,11 +7,19 @@ export class SignedGodTokenDTO {
}
export class RetrieveFriendDTO {
- @IsNotEmpty()
+ @ValidateIf((rfd) => !rfd.name || rfd.id)
name: string;
+
+ @ValidateIf((rfd) => !rfd.id || rfd.name)
+ id: number;
}
export class CreateTimerDTO {
@IsNotEmpty()
name: string;
}
+
+export class RefreshTimerDTO {
+ @Type(() => Number)
+ id: number;
+}
diff --git a/server/src/main.ts b/server/src/main.ts
index a9fe41c..ae21f73 100644
--- a/server/src/main.ts
+++ b/server/src/main.ts
@@ -1,6 +1,7 @@
import { NestFactory } from '@nestjs/core';
import { Logger, ValidationPipe } from '@nestjs/common';
import { AppModule } from './app.module';
+import { AuthenticatedSocketIoAdapter } from './auth/authServer.adapter';
import * as cookieParser from 'cookie-parser';
@@ -10,6 +11,11 @@ async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.use(cookieParser());
+ // TODO: Remove
+ app.enableCors();
+
+ // All WS connections must be auth'd
+ app.useWebSocketAdapter(new AuthenticatedSocketIoAdapter(app));
app.useGlobalPipes(
new ValidationPipe({
diff --git a/server/src/timer/timer.controller.ts b/server/src/timer/timer.controller.ts
index 03b9790..f6bcc42 100644
--- a/server/src/timer/timer.controller.ts
+++ b/server/src/timer/timer.controller.ts
@@ -3,6 +3,7 @@ import {
Get,
Post,
Body,
+ Param,
Req,
Query,
NotFoundException,
@@ -10,8 +11,13 @@ import {
BadRequestException,
} from '@nestjs/common';
import { TimerService } from './timer.service';
+import { TimerGateway } from './timer.gateway';
import { AuthService } from '../auth/auth.service';
-import { CreateTimerDTO, RetrieveFriendDTO } from '../dto/dtos';
+import {
+ RefreshTimerDTO,
+ CreateTimerDTO,
+ RetrieveFriendDTO,
+} from '../dto/dtos';
import { AuthGuard } from 'src/auth/auth.guard';
import { Prisma } from '@prisma/client';
@@ -19,20 +25,36 @@ import { Prisma } from '@prisma/client';
@UseGuards(AuthGuard)
export class TimerController {
constructor(
+ private readonly timerGateway: TimerGateway,
private readonly timerService: TimerService,
private readonly authService: AuthService,
) {}
@Get()
public async getAllTimers() {
- return this.timerService.getAll();
+ return await this.timerService.getAll();
}
@Get('/friend')
- public async getFriendTimers(@Query() { name }: RetrieveFriendDTO) {
- const friend = await this.authService.findFriendByName(name);
- if (!friend) throw new NotFoundException('Friend not found with that name');
- return this.timerService.friendTimers(friend);
+ public async getFriendTimers(@Query() { id, name }: RetrieveFriendDTO) {
+ const friend = await this.authService.findFriendByNameOrId(name, id);
+ if (!friend) throw new NotFoundException('Friend not found by that query');
+
+ return await this.timerService.friendTimers(friend);
+ }
+
+ @Post('/:id/refresh')
+ public async refreshTimer(@Param() { id }: RefreshTimerDTO, @Req() req) {
+ const timer = await this.timerService.findTimerById(id);
+ if (!timer) throw new NotFoundException('No such timer with id');
+
+ const refreshedTimer = await this.timerService.refreshTimer(
+ timer,
+ req.friend,
+ );
+ this.timerGateway.timerRefreshed(refreshedTimer);
+
+ return refreshedTimer;
}
@Post()
@@ -50,8 +72,9 @@ export class TimerController {
'Can link no more than 10 unique friends to timer',
);
+ let timer;
try {
- return await this.timerService.createTimerWithFriends(
+ timer = await this.timerService.createTimerWithFriends(
{
name,
created_by: {
@@ -71,5 +94,9 @@ export class TimerController {
throw e;
}
+
+ this.timerGateway.timerCreated(timer);
+
+ return timer;
}
}
diff --git a/server/src/timer/timer.gateway.ts b/server/src/timer/timer.gateway.ts
index 5b88289..5809f8d 100644
--- a/server/src/timer/timer.gateway.ts
+++ b/server/src/timer/timer.gateway.ts
@@ -1,9 +1,109 @@
-import { SubscribeMessage, WebSocketGateway } from '@nestjs/websockets';
+import {
+ WebSocketGateway,
+ WebSocketServer,
+ OnGatewayConnection,
+ OnGatewayDisconnect,
+} from '@nestjs/websockets';
+import { Inject, forwardRef } from '@nestjs/common';
+import { Server, Socket } from 'socket.io';
+import { TimerService } from './timer.service';
+import { AuthService } from '../auth/auth.service';
+import { Timer, Friend } from '@prisma/client';
-@WebSocketGateway()
-export class TimerGateway {
- @SubscribeMessage('message')
- handleMessage(client: any, payload: any): string {
- return 'Hello world!';
+@WebSocketGateway({
+ transports: ['websocket'],
+ namespace: '/events/timers',
+})
+export class TimerGateway implements OnGatewayConnection, OnGatewayDisconnect {
+ constructor(
+ @Inject(forwardRef(() => TimerService))
+ private timerService: TimerService,
+ private authService: AuthService,
+ ) {}
+
+ @WebSocketServer()
+ server: Server;
+
+ private roomsClientsConnected: Map> = new Map<
+ string,
+ Set
+ >();
+
+ private clientRoomsConnected: Map> = new Map<
+ string,
+ Set
+ >();
+
+ private addClientToRoom(roomName: string, clientId: string) {
+ if (!this.roomsClientsConnected.has(roomName)) {
+ this.roomsClientsConnected.set(roomName, new Set([clientId]));
+ } else {
+ this.roomsClientsConnected.get(roomName).add(clientId);
+ }
+
+ if (!this.clientRoomsConnected.has(clientId)) {
+ this.clientRoomsConnected.set(clientId, new Set([roomName]));
+ } else {
+ this.clientRoomsConnected.get(clientId).add(roomName);
+ }
+ }
+
+ private removeClientFromRoom(roomName: string, clientId: string) {
+ if (this.roomsClientsConnected.has(roomName)) {
+ const clients = this.roomsClientsConnected.get(roomName);
+
+ if (clients.size === 1) {
+ this.roomsClientsConnected.delete(roomName);
+ } else {
+ clients.delete(clientId);
+ }
+ }
+
+ if (this.clientRoomsConnected.has(clientId)) {
+ const clientRooms = this.clientRoomsConnected.get(clientId);
+ clientRooms.delete(roomName);
+
+ if (clientRooms.size === 0) this.clientRoomsConnected.delete(clientId);
+ }
+ }
+
+ private friendRoom = ({ id }: { id: number }) => `friend-${id}`;
+
+ public async handleConnection(client: Socket) {
+ let roomName = 'all';
+
+ const { friend } = client.handshake?.query;
+ if (friend) {
+ const listenFriend = isNaN(Number(friend))
+ ? await this.authService.findFriendByName(friend as string)
+ : await this.authService.findFriendByNameOrId('', Number(friend));
+ roomName = this.friendRoom(listenFriend);
+ }
+
+ client.join(roomName);
+ this.addClientToRoom(roomName, client.id);
+ }
+
+ public handleDisconnect(client: Socket) {
+ for (const room of this.clientRoomsConnected.get(client.id))
+ this.removeClientFromRoom(room, client.id);
+ }
+
+ public timerCreated(timer: Timer & { referenced_friends: Friend[] }) {
+ this.server.to('all').emit('created', timer);
+ timer.referenced_friends.map((friend) =>
+ this.server.to(this.friendRoom(friend)).emit('created', timer),
+ );
+ }
+
+ public timerRefreshed(
+ timer: Partial & { referenced_friends: Partial[] },
+ ) {
+ this.server.to('all').emit('refreshed', timer);
+ timer.referenced_friends.map((friend) =>
+ this.server
+ .to(this.friendRoom(friend as Friend))
+ .emit('refreshed', timer),
+ );
}
}
diff --git a/server/src/timer/timer.service.ts b/server/src/timer/timer.service.ts
index 25f6cbe..d885603 100644
--- a/server/src/timer/timer.service.ts
+++ b/server/src/timer/timer.service.ts
@@ -1,30 +1,32 @@
-import { Friend, Prisma } from '@prisma/client';
+import { Friend, Timer, Prisma } from '@prisma/client';
import { Injectable } from '@nestjs/common';
-
import { PrismaService } from '../prisma/prisma.service';
+import { AuthService } from '../auth/auth.service';
@Injectable()
export class TimerService {
constructor(private readonly prismaService: PrismaService) {}
+ static TIMER_SELECT = {
+ id: true,
+ name: true,
+ start: true,
+ } as Prisma.TimerSelect;
+
+ static INCLUDE_FRIENDS_SELECT = {
+ referenced_friends: {
+ select: AuthService.FRIEND_SELECT,
+ },
+ created_by: {
+ select: AuthService.FRIEND_SELECT,
+ },
+ };
+
public getAll() {
return this.prismaService.timer.findMany({
select: {
- id: true,
- name: true,
- start: true,
- referenced_friends: {
- select: {
- name: true,
- id: true,
- },
- },
- created_by: {
- select: {
- name: true,
- id: true,
- },
- },
+ ...TimerService.TIMER_SELECT,
+ ...TimerService.INCLUDE_FRIENDS_SELECT,
},
});
}
@@ -32,41 +34,82 @@ export class TimerService {
public friendTimers(friend: Friend) {
return this.prismaService.timer.findMany({
select: {
- id: true,
- name: true,
- start: true,
+ ...TimerService.TIMER_SELECT,
referenced_friends: {
where: {
id: friend.id,
},
- select: {
- id: true,
- name: true,
- },
+ select: AuthService.FRIEND_SELECT,
},
created_by: {
- select: {
- name: true,
- id: true,
- },
+ select: AuthService.FRIEND_SELECT,
},
},
});
}
+ public findTimerById(id: number) {
+ return this.prismaService.timer.findUnique({
+ where: { id },
+ });
+ }
+
+ public async refreshTimer(timer: Timer, friend: Friend) {
+ const now = new Date();
+ const select = {
+ ...TimerService.TIMER_SELECT,
+ ...TimerService.INCLUDE_FRIENDS_SELECT,
+ };
+ const refreshedTimer = await this.prismaService.timer.update({
+ where: { id: timer.id },
+ data: {
+ start: now,
+ },
+ select,
+ });
+
+ await this.prismaService.timerRefreshes.create({
+ data: {
+ start: timer.start,
+ end: now,
+ refreshed_by: {
+ connect: {
+ id: friend.id,
+ },
+ },
+ timer: {
+ connect: {
+ id: timer.id,
+ },
+ },
+ },
+ });
+
+ return refreshedTimer;
+ }
+
public createTimerWithFriends(
timer: Prisma.TimerCreateInput,
- friendIds: number[],
+ referencedFriendIds: number[],
) {
- if (friendIds.length > 0)
+ const select = {
+ ...TimerService.TIMER_SELECT,
+ ...TimerService.INCLUDE_FRIENDS_SELECT,
+ };
+ if (referencedFriendIds.length > 0)
return this.prismaService.timer.create({
data: {
...timer,
referenced_friends: {
- connect: friendIds.map((id) => ({ id })),
+ connect: referencedFriendIds.map((id) => ({ id })),
},
},
+ select,
});
- return this.prismaService.timer.create({ data: timer });
+
+ return this.prismaService.timer.create({
+ data: timer,
+ select,
+ });
}
}