Implement session invites

This commit is contained in:
Pijus Kamandulis 2024-10-12 00:04:36 +03:00
parent 9b45d372fa
commit 1463c05731
16 changed files with 795 additions and 1 deletions

1
.env
View File

@ -2,3 +2,4 @@ VITE_APPWRITE_ENDPOINT=https://cloud.appwrite.io/v1
VITE_APPWRITE_PROJECT_ID=scrummie-poker VITE_APPWRITE_PROJECT_ID=scrummie-poker
VITE_APPWRITE_DATABASE_ID=670402eb000f5aff721f VITE_APPWRITE_DATABASE_ID=670402eb000f5aff721f
VITE_APPWRITE_ESTIMATION_SESSION_COLLECTION_ID=670402f60023cb78d441 VITE_APPWRITE_ESTIMATION_SESSION_COLLECTION_ID=670402f60023cb78d441
VITE_SESSION_INVITE_FUNCTION_ID=6708356a001290606744

109
appwrite.json Normal file
View File

@ -0,0 +1,109 @@
{
"projectId": "scrummie-poker",
"projectName": "ScrummiePoker",
"settings": {
"services": {
"account": true,
"avatars": true,
"databases": true,
"locale": true,
"health": true,
"storage": true,
"teams": true,
"users": true,
"functions": true,
"graphql": true,
"messaging": true
},
"auth": {
"methods": {
"jwt": true,
"phone": true,
"invites": true,
"anonymous": true,
"email-otp": true,
"magic-url": true,
"email-password": true
},
"security": {
"duration": 31536000,
"limit": 0,
"sessionsLimit": 10,
"passwordHistory": 0,
"passwordDictionary": false,
"personalDataCheck": false,
"sessionAlerts": false,
"mockNumbers": []
}
}
},
"functions": [
{
"$id": "6708356a001290606744",
"execute": ["users"],
"name": "EstimationSessionInvite",
"enabled": true,
"logging": true,
"runtime": "bun-1.0",
"scopes": ["users.read", "documents.read", "documents.write"],
"events": [],
"schedule": "",
"timeout": 15,
"entrypoint": "src/main.ts",
"commands": "bun install",
"path": "functions/EstimationSessionInvite"
}
],
"databases": [
{
"$id": "670402eb000f5aff721f",
"name": "estimations",
"enabled": true
}
],
"collections": [
{
"$id": "670402f60023cb78d441",
"$permissions": ["create(\"users\")"],
"databaseId": "670402eb000f5aff721f",
"name": "sessions",
"enabled": true,
"documentSecurity": true,
"attributes": [
{
"key": "userId",
"type": "string",
"required": true,
"array": false,
"size": 50,
"default": null
},
{
"key": "name",
"type": "string",
"required": true,
"array": false,
"size": 200,
"default": null
},
{
"key": "tickets",
"type": "string",
"required": false,
"array": true,
"size": 100,
"default": null
},
{
"key": "sessionState",
"type": "string",
"required": false,
"array": false,
"size": 1000,
"default": null
}
],
"indexes": []
}
]
}

View File

@ -0,0 +1,148 @@
### Node ###
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
### Node Patch ###
# Serverless Webpack directories
.webpack/
# Optional stylelint cache
# SvelteKit build / generate output
.svelte-kit
# End of https://www.toptal.com/developers/gitignore/api/node
# OS
## Mac
.DS_Store
# Directory used by Appwrite CLI for local development
.appwrite

View File

@ -0,0 +1,39 @@
# Estimation Session Invite Function
This function allows other users to join an existing estimation session by providing it's Id.
## Usage
### GET /?action=get-info&estimationId=[session-id]
- Gets session's information by id
**Response**
Sample `200` Response:
```json
{
"id": "session-id",
"name": "session name"
}
```
### GET /?action=join&estimationId=[session-id]
- Joins session by id
**Response**
Sample `200` Response:
```json
{
"message": "Estimation session joined"
}
```
## Environment Variables
- APPWRITE_DATABASE_ID - Database Id
- APPWRITE_ESTIMATION_SESSION_COLLECTION_ID - Sessions collection Id

Binary file not shown.

View File

@ -0,0 +1,10 @@
declare module 'bun' {
interface Env {
APPWRITE_FUNCTION_API_ENDPOINT: string;
APPWRITE_FUNCTION_PROJECT_ID: string;
APPWRITE_DATABASE_ID: string;
APPWRITE_ESTIMATION_SESSION_COLLECTION_ID: string;
}
}
export {};

View File

@ -0,0 +1,88 @@
{
"name": "estimation-session-invite",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "estimation-session-invite",
"version": "1.0.0",
"dependencies": {
"node-appwrite": "^14.1.0"
},
"devDependencies": {
"@types/bun": "^1.1.11",
"prettier": "^3.3.3"
}
},
"node_modules/@types/bun": {
"version": "1.1.11",
"resolved": "https://registry.npmjs.org/@types/bun/-/bun-1.1.11.tgz",
"integrity": "sha512-0N7D/H/8sbf9JMkaG5F3+I/cB4TlhKTkO9EskEWP8XDr8aVcDe4EywSnU4cnyZy6tar1dq70NeFNkqMEUigthw==",
"dev": true,
"dependencies": {
"bun-types": "1.1.30"
}
},
"node_modules/@types/node": {
"version": "20.12.14",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.14.tgz",
"integrity": "sha512-scnD59RpYD91xngrQQLGkE+6UrHUPzeKZWhhjBSa3HSkwjbQc38+q3RoIVEwxQGRw3M+j5hpNAM+lgV3cVormg==",
"dev": true,
"dependencies": {
"undici-types": "~5.26.4"
}
},
"node_modules/@types/ws": {
"version": "8.5.12",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.12.tgz",
"integrity": "sha512-3tPRkv1EtkDpzlgyKyI8pGsGZAGPEaXeu0DOj5DI25Ja91bdAYddYHbADRYVrZMRbfW+1l5YwXVDKohDJNQxkQ==",
"dev": true,
"dependencies": {
"@types/node": "*"
}
},
"node_modules/bun-types": {
"version": "1.1.30",
"resolved": "https://registry.npmjs.org/bun-types/-/bun-types-1.1.30.tgz",
"integrity": "sha512-mGh7NLisOXskBU62DxLS+/nwmLlCYHYAkCzdo4DZ9+fzrpP41hAdOqaN4DO6tQfenHb4pYb0/shw29k4/6I2yQ==",
"dev": true,
"dependencies": {
"@types/node": "~20.12.8",
"@types/ws": "~8.5.10"
}
},
"node_modules/node-appwrite": {
"version": "14.1.0",
"license": "BSD-3-Clause",
"dependencies": {
"node-fetch-native-with-agent": "1.7.2"
}
},
"node_modules/node-fetch-native-with-agent": {
"version": "1.7.2",
"license": "MIT"
},
"node_modules/prettier": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.3.tgz",
"integrity": "sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==",
"dev": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/undici-types": {
"version": "5.26.5",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
"integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
"dev": true
}
}
}

View File

@ -0,0 +1,18 @@
{
"name": "estimation-session-invite",
"version": "1.0.0",
"description": "",
"main": "src/main.ts",
"type": "module",
"scripts": {
"format": "prettier --write ."
},
"dependencies": {
"node-appwrite": "^14.1.0"
},
"devDependencies": {
"@types/bun": "^1.1.11",
"bun-types": "^1.1.30",
"prettier": "^3.3.3"
}
}

View File

@ -0,0 +1,49 @@
declare type AppwriteRequest = {
bodyRaw: string;
body: Object;
headers: Object;
scheme: string;
method: string;
url: string;
host: string;
port: number;
path: string;
queryString: string;
query: Object;
};
declare type AppwriteSendReturn = {
body: any;
statusCode: number;
headers: Object;
};
declare type AppwriteResponse = {
empty: () => AppwriteSendReturn;
json: (obj: any, statusCode?: number, headers?: Object) => AppwriteSendReturn;
text: (text: string) => AppwriteSendReturn;
redirect: (
url: string,
statusCode?: number,
headers?: Object,
) => AppwriteSendReturn;
send: (
body: any,
statusCode?: number,
headers?: Object,
) => AppwriteSendReturn;
};
declare type AppwriteRuntimeContext = {
req: AppwriteRequest;
res: AppwriteResponse;
log: (message: any) => void;
error: (message: any) => void;
};
export {
AppwriteRequest,
AppwriteSendReturn,
AppwriteResponse,
AppwriteRuntimeContext,
};

View File

@ -0,0 +1,148 @@
import { Client, Databases } from 'node-appwrite';
import { AppwriteRuntimeContext, AppwriteSendReturn } from './definitions.mjs';
const joinSession = async ({
client,
estimationId,
userId,
ctx: { error, res },
}: {
client: Client;
estimationId: string;
userId: string;
ctx: AppwriteRuntimeContext;
}): Promise<AppwriteSendReturn> => {
const databases = new Databases(client);
let estimation;
try {
estimation = await databases.getDocument(
Bun.env.APPWRITE_DATABASE_ID,
Bun.env.APPWRITE_ESTIMATION_SESSION_COLLECTION_ID,
estimationId,
);
} catch (e) {
error({ e });
return res.json(
{
message: 'Estimation with this id does not exist',
},
400,
);
}
try {
const permissions: string[] = estimation['$permissions'];
permissions.push(`read("user:${userId}")`);
permissions.push(`update("user:${userId}")`);
await databases.updateDocument(
Bun.env.APPWRITE_DATABASE_ID,
Bun.env.APPWRITE_ESTIMATION_SESSION_COLLECTION_ID,
estimationId,
{},
permissions,
);
return res.json({
message: 'Estimation session joined',
});
} catch (e) {
error({ e });
return res.json(
{
message: 'Failed to join estimation session',
},
500,
);
}
};
const getSessionInfo = async ({
client,
estimationId,
ctx: { log, res },
}: {
client: Client;
estimationId: string;
ctx: AppwriteRuntimeContext;
}): Promise<AppwriteSendReturn> => {
const databases = new Databases(client);
try {
const estimation = await databases.getDocument(
Bun.env.APPWRITE_DATABASE_ID,
Bun.env.APPWRITE_ESTIMATION_SESSION_COLLECTION_ID,
estimationId,
);
return res.json({
result: {
id: estimation.$id,
name: estimation.name,
},
});
} catch (e) {
console.log({ e });
log({ e });
return res.json(
{
message: 'Estimation with this id does not exist',
},
400,
);
}
};
export default async (ctx: AppwriteRuntimeContext) => {
const { req, res } = ctx;
const userId = req.headers['x-appwrite-user-id'];
if (!userId) {
return res.json(
{
message: 'Unauthorized',
},
401,
);
}
const action = req.query['action'];
const estimationId = req.query['estimationId'];
if (!action || !estimationId) {
return res.json(
{
message: 'Bad request',
},
400,
);
}
const client = new Client()
.setEndpoint(Bun.env.APPWRITE_FUNCTION_API_ENDPOINT)
.setProject(Bun.env.APPWRITE_FUNCTION_PROJECT_ID)
.setKey(req.headers['x-appwrite-key'] ?? '');
if (action === 'get-info') {
return await getSessionInfo({
client,
ctx,
estimationId,
});
}
if (action === 'join') {
return await joinSession({
client,
estimationId,
userId,
ctx,
});
}
return res.json(
{
message: 'Not found',
},
404,
);
};

View File

@ -0,0 +1,5 @@
{
"compilerOptions": {
"types": ["bun-types"]
}
}

View File

@ -1,4 +1,4 @@
import { Client, Account, Databases } from 'appwrite'; import { Client, Account, Databases, Functions } from 'appwrite';
export const client = new Client(); export const client = new Client();
@ -10,7 +10,10 @@ export { ID } from 'appwrite';
export const account = new Account(client); export const account = new Account(client);
export const databases = new Databases(client); export const databases = new Databases(client);
export const functions = new Functions(client);
export const DATABASE_ID = import.meta.env.VITE_APPWRITE_DATABASE_ID; export const DATABASE_ID = import.meta.env.VITE_APPWRITE_DATABASE_ID;
export const ESTIMATION_SESSION_COLLECTION_ID = import.meta.env export const ESTIMATION_SESSION_COLLECTION_ID = import.meta.env
.VITE_APPWRITE_ESTIMATION_SESSION_COLLECTION_ID; .VITE_APPWRITE_ESTIMATION_SESSION_COLLECTION_ID;
export const SESSION_INVITE_FUNCTION_ID = import.meta.env
.VITE_SESSION_INVITE_FUNCTION_ID;

View File

@ -0,0 +1,88 @@
import { ExecutionMethod } from 'appwrite';
import { functions, SESSION_INVITE_FUNCTION_ID } from '../appwrite';
type SessionInviteInfo =
| {
success: true;
id: string;
name: string;
}
| {
success: false;
message: string;
};
type JoinSessionResponse =
| {
success: true;
}
| {
success: false;
message: string;
};
const getInviteInfo = async (sessionId: string): Promise<SessionInviteInfo> => {
const result = await functions.createExecution(
SESSION_INVITE_FUNCTION_ID,
undefined,
false,
`/?action=get-info&estimationId=${sessionId}`,
ExecutionMethod.GET,
{},
);
if (result.status === 'failed') {
return {
success: false,
message: result.errors ?? 'Failed to get estimation session info',
};
}
const responseBody = JSON.parse(result.responseBody);
if (responseBody.message) {
return {
success: false,
message: responseBody.message,
};
}
const { id, name } = responseBody.result;
return {
success: true,
id,
name,
};
};
const joinSession = async (sessionId: string): Promise<JoinSessionResponse> => {
const result = await functions.createExecution(
SESSION_INVITE_FUNCTION_ID,
undefined,
false,
`/?action=join&estimationId=${sessionId}`,
ExecutionMethod.GET,
{},
);
if (result.status === 'failed') {
return {
success: false,
message: result.errors ?? 'Failed to join session',
};
}
const responseBody = JSON.parse(result.responseBody);
if (responseBody.message) {
return {
success: false,
message: responseBody.message,
};
}
return {
success: true,
};
};
export { getInviteInfo, joinSession };
export type { SessionInviteInfo };

View File

@ -17,6 +17,7 @@ import { EstimationContextProvider } from './lib/context/estimation';
import Estimation from './pages/Estimation/Estimation'; import Estimation from './pages/Estimation/Estimation';
import Header from './components/Header'; import Header from './components/Header';
import Profile from './pages/Profile'; import Profile from './pages/Profile';
import Join from './pages/Join';
interface RouterContext { interface RouterContext {
userContext: UserContextType; userContext: UserContextType;
@ -68,6 +69,12 @@ const profileRoute = createRoute({
getParentRoute: () => authenticatedRoute, getParentRoute: () => authenticatedRoute,
}); });
const joinRoute = createRoute({
path: 'join/$sessionId',
component: Join,
getParentRoute: () => authenticatedRoute,
});
const estimationSessionRoute = createRoute({ const estimationSessionRoute = createRoute({
path: 'estimate/session/$sessionId', path: 'estimate/session/$sessionId',
component: Estimation, component: Estimation,
@ -77,6 +84,7 @@ const estimationSessionRoute = createRoute({
const router = createRouter({ const router = createRouter({
routeTree: rootRoute.addChildren([ routeTree: rootRoute.addChildren([
authenticatedRoute.addChildren([ authenticatedRoute.addChildren([
joinRoute,
indexRoute, indexRoute,
profileRoute, profileRoute,
estimationSessionRoute, estimationSessionRoute,

79
src/pages/Join.tsx Normal file
View File

@ -0,0 +1,79 @@
import { useEffect, useState } from 'react';
import {
getInviteInfo,
joinSession,
SessionInviteInfo,
} from '../lib/functions/estimationSessionInvite';
import { getRouteApi } from '@tanstack/react-router';
const route = getRouteApi('/_authenticated/join/$sessionId');
const Join = () => {
const navigate = route.useNavigate();
const { sessionId } = route.useParams();
const [sessionInfo, setSessionInfo] = useState<SessionInviteInfo>();
const [isLoading, setIsLoading] = useState(true);
useEffect(() => {
getInviteInfo(sessionId).then((sessionInfo) => {
setSessionInfo(sessionInfo);
setIsLoading(false);
});
}, [sessionId]);
const handleAccept = async () => {
setIsLoading(true);
await joinSession(sessionId);
navigate({
to: '/estimate/session/$sessionId',
params: {
sessionId: sessionId,
},
});
};
const handleReturnHome = () => {
navigate({
to: '/',
});
};
if (!sessionInfo || isLoading) {
// TODO: add loader
return <p>Loading...</p>;
}
if (!sessionInfo.success) {
return <p>{sessionInfo.message}</p>;
}
return (
<div className="flex min-h-screen flex-col items-center justify-center bg-gray-50 transition-colors dark:bg-nero-900">
<div className="max-w-lg rounded-lg bg-white p-8 text-center shadow-lg dark:bg-nero-800">
<h1 className="mb-4 text-2xl font-semibold text-gray-900 dark:text-gray-100">
You have been invited to join a new estimation session!
</h1>
<p className="mb-6 text-lg text-gray-700 dark:text-gray-300">
Session Name: <strong>{sessionInfo.name}</strong>
</p>
<div className="flex justify-center gap-4">
<button
onClick={handleAccept}
className="rounded-md bg-indigo-600 px-6 py-2 text-sm font-semibold text-white shadow-sm transition-colors hover:bg-indigo-500"
>
Accept
</button>
<button
onClick={handleReturnHome}
className="rounded-md bg-gray-300 px-6 py-2 text-sm font-semibold text-gray-900 shadow-sm transition-colors hover:bg-gray-200 dark:bg-nero-700 dark:text-gray-100 dark:hover:bg-gray-600"
>
Return to Home
</button>
</div>
</div>
</div>
);
};
export default Join;

1
src/vite-env.d.ts vendored
View File

@ -5,6 +5,7 @@ interface ImportMetaEnv {
readonly VITE_APPWRITE_PROJECT_ID: string; readonly VITE_APPWRITE_PROJECT_ID: string;
readonly VITE_APPWRITE_DATABASE_ID: string; readonly VITE_APPWRITE_DATABASE_ID: string;
readonly VITE_APPWRITE_ESTIMATION_SESSION_COLLECTION_ID: string; readonly VITE_APPWRITE_ESTIMATION_SESSION_COLLECTION_ID: string;
readonly VITE_SESSION_INVITE_FUNCTION_ID: string;
} }
interface ImportMeta { interface ImportMeta {