Allow user to set a username; Show list of users on estimation screen

This commit is contained in:
Pijus Kamandulis 2024-10-12 17:20:16 +03:00
parent f4d3005acd
commit 40b1ef6f0c
18 changed files with 423 additions and 45 deletions

View File

@ -52,6 +52,21 @@
"entrypoint": "src/main.ts",
"commands": "bun install",
"path": "functions/EstimationSessionInvite"
},
{
"$id": "670a4b770001c5a71194",
"execute": [],
"name": "UsernameChangeHandler",
"enabled": true,
"logging": true,
"runtime": "go-1.23",
"scopes": ["users.read", "documents.read", "documents.write"],
"events": ["users.*.update.name"],
"schedule": "",
"timeout": 15,
"entrypoint": "main.go",
"commands": "",
"path": "functions/UsernameChangeHandler"
}
],
"databases": [
@ -101,6 +116,22 @@
"array": false,
"size": 1000,
"default": null
},
{
"key": "players",
"type": "string",
"required": false,
"array": true,
"size": 100,
"default": null
},
{
"key": "playerIds",
"type": "string",
"required": false,
"array": true,
"size": 100,
"default": null
}
],
"indexes": []

View File

@ -1,4 +1,4 @@
import { Client, Databases } from 'node-appwrite';
import { Client, Databases, Models, Users } from 'node-appwrite';
import { AppwriteRuntimeContext, AppwriteSendReturn } from './definitions.mjs';
const joinSession = async ({
@ -13,6 +13,7 @@ const joinSession = async ({
ctx: AppwriteRuntimeContext;
}): Promise<AppwriteSendReturn> => {
const databases = new Databases(client);
const users = new Users(client);
let estimation;
try {
@ -31,16 +32,43 @@ const joinSession = async ({
);
}
let user: Models.User<Models.Preferences>;
try {
user = await users.get(userId);
} catch (e) {
error({ e });
return res.json(
{
message: 'Failed to get user',
},
500,
);
}
try {
const permissions: string[] = estimation['$permissions'];
permissions.push(`read("user:${userId}")`);
permissions.push(`update("user:${userId}")`);
const playerIds: string[] = estimation['playerIds'];
playerIds.push(userId);
const players: string[] = estimation['players'];
players.push(
JSON.stringify({
userId,
name: user.name.length > 0 ? user.name : `Guest - ${userId}`,
}),
);
await databases.updateDocument(
Bun.env.APPWRITE_DATABASE_ID,
Bun.env.APPWRITE_ESTIMATION_SESSION_COLLECTION_ID,
estimationId,
{},
{
playerIds,
players,
},
permissions,
);

View File

@ -0,0 +1,2 @@
# Directory used by Appwrite CLI for local development
.appwrite

View File

@ -0,0 +1,10 @@
# Username Change Handler
This function is called when a user changes his username.
Currently this funcion updates users name accross all of his estimation sessions.
## Environment Variables
- APPWRITE_DATABASE_ID - Database Id
- APPWRITE_ESTIMATION_SESSION_COLLECTION_ID - Sessions collection Id

View File

@ -0,0 +1,7 @@
module openruntimes/handler
go 1.23.0
require github.com/open-runtimes/types-for-go/v4 v4.0.6
require github.com/appwrite/sdk-for-go v0.0.1-rc.2

View File

@ -0,0 +1,4 @@
github.com/appwrite/sdk-for-go v0.0.1-rc.2 h1:kh8p6OmSgA4d7aT1KXE9Z3W99ioDKdhhY1OrKsTLu1I=
github.com/appwrite/sdk-for-go v0.0.1-rc.2/go.mod h1:aFiOAbfOzGS3811eMCt3T9WDBvjvPVAfOjw10Vghi4E=
github.com/open-runtimes/types-for-go/v4 v4.0.6 h1:0Xf58LMy/vwWkiRN6BvvpWt1mWzcWUWQ5wsWSezG2TU=
github.com/open-runtimes/types-for-go/v4 v4.0.6/go.mod h1:ab4mDSfgeG4kN8wWpaBSv0Ao3m9P6oEfN5gsXtx+iaI=

View File

@ -0,0 +1,134 @@
package handler
import (
"encoding/json"
"os"
"github.com/appwrite/sdk-for-go/appwrite"
"github.com/appwrite/sdk-for-go/models"
"github.com/appwrite/sdk-for-go/query"
"github.com/open-runtimes/types-for-go/v4/openruntimes"
)
type Response struct {
Message string `json:"message"`
}
type ErrorResponse struct {
Error string `json:"error"`
}
type EstimationSession struct {
Id string `json:"$id"`
UserID string `json:"userId"`
Name string `json:"name"`
Tickets []string `json:"tickets"`
SessionState string `json:"sessionState"`
Players []string `json:"players"`
PlayerIDs []string `json:"playerIds"`
}
type EstimationSessionList struct {
Total int `json:"total"`
Documents []EstimationSession `json:"documents"`
}
type Player struct {
UserID string `json:"userId"`
Name string `json:"name"`
}
func Main(Context openruntimes.Context) openruntimes.Response {
databaseId := os.Getenv("APPWRITE_DATABASE_ID")
collectionId := os.Getenv("APPWRITE_ESTIMATION_SESSION_COLLECTION_ID")
if databaseId == "" || collectionId == "" {
Context.Error("Environment variables not provided")
return Context.Res.Json(ErrorResponse{
Error: "Environment variables not provided",
}, Context.Res.WithStatusCode(500))
}
var userData models.User
Context.Req.BodyJson(&userData)
if userData.Id == "" {
Context.Log("Request body did not contain id")
return Context.Res.Json(ErrorResponse{
Error: "User id was not provided",
}, Context.Res.WithStatusCode(400))
}
client := appwrite.NewClient(
appwrite.WithEndpoint(os.Getenv("APPWRITE_FUNCTION_API_ENDPOINT")),
appwrite.WithProject(os.Getenv("APPWRITE_FUNCTION_PROJECT_ID")),
appwrite.WithKey(Context.Req.Headers["x-appwrite-key"]),
)
databases := appwrite.NewDatabases(client)
queries := []string{query.Contains("playerIds", userData.Id)}
userEstimationSessions, err := databases.ListDocuments(databaseId, collectionId, databases.WithListDocumentsQueries(queries))
if err != nil {
Context.Error(err.Error())
return Context.Res.Json(ErrorResponse{
Error: err.Error(),
}, Context.Res.WithStatusCode(500))
}
var documents EstimationSessionList
err = userEstimationSessions.Decode(&documents)
if err != nil {
Context.Error(err.Error())
return Context.Res.Json(ErrorResponse{
Error: err.Error(),
}, Context.Res.WithStatusCode(500))
}
newUsername := userData.Name
if newUsername == "" {
newUsername = "Guest - " + userData.Id
}
for _, estimationSession := range documents.Documents {
for i, jsonString := range estimationSession.Players {
var player Player
err := json.Unmarshal([]byte(jsonString), &player)
if err != nil {
Context.Error(err.Error())
return Context.Res.Json(ErrorResponse{
Error: err.Error(),
}, Context.Res.WithStatusCode(500))
}
if player.UserID == userData.Id {
player.Name = newUsername
updatedPlayer, err := json.Marshal(player)
if err != nil {
Context.Error(err.Error())
return Context.Res.Json(ErrorResponse{
Error: err.Error(),
}, Context.Res.WithStatusCode(500))
}
estimationSession.Players[i] = string(updatedPlayer)
break
}
}
patch := map[string]any{"players": estimationSession.Players}
_, err := databases.UpdateDocument(databaseId, collectionId, estimationSession.Id, databases.WithUpdateDocumentData(patch))
if err != nil {
Context.Error(err.Error())
return Context.Res.Json(ErrorResponse{
Error: err.Error(),
}, Context.Res.WithStatusCode(500))
}
Context.Log("Updated estimation: ", estimationSession.Id)
}
return Context.Res.Json(Response{
Message: "Updated player name",
})
}

View File

@ -3,13 +3,20 @@ import { useState } from 'react';
import { useUser } from '../lib/context/user';
const Header = () => {
const { logout } = useUser();
const { current, isLoading, logout } = useUser();
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const toggleDropdown = () => {
setIsDropdownOpen(!isDropdownOpen);
};
if (isLoading || !current) {
return null;
}
const userName =
current.name.length > 0 ? current.name : `Guest - ${current.$id}`;
return (
<header className="bg-white shadow-md transition-colors dark:bg-nero-700">
<nav
@ -30,7 +37,7 @@ const Header = () => {
className="text-sm font-semibold leading-6 text-gray-900 dark:text-gray-100"
onClick={toggleDropdown}
>
Account <span aria-hidden="true">&darr;</span>
{userName} <span aria-hidden="true">&darr;</span>
</button>
{isDropdownOpen && (

View File

@ -52,7 +52,7 @@ export const EstimationContextProvider = (props: PropsWithChildren) => {
sessionId,
)
.then((payload) => {
const userId = userData?.$id ?? ''; // TODO: Not sure if this is the user id or session
const userId = userData?.$id ?? '';
setCurrentSessionData(mapDatabaseToEntity(payload, { userId }));
});
@ -61,7 +61,7 @@ export const EstimationContextProvider = (props: PropsWithChildren) => {
`databases.${DATABASE_ID}.collections.${ESTIMATION_SESSION_COLLECTION_ID}.documents.${sessionId}`,
],
({ payload }) => {
const userId = userData?.$id ?? ''; // TODO: Not sure if this is the user id or session
const userId = userData?.$id ?? '';
setCurrentSessionData(mapDatabaseToEntity(payload, { userId }));
},
);
@ -98,7 +98,7 @@ export const EstimationContextProvider = (props: PropsWithChildren) => {
};
const setVote = async (estimate: string) => {
const userId = userData?.$id ?? ''; // TODO: Not sure if this is the user id or session
const userId = userData?.$id ?? '';
await updateSessionState({
votes: currentSessionData?.sessionState.votes
.filter((x) => x.userId !== userId)
@ -106,6 +106,7 @@ export const EstimationContextProvider = (props: PropsWithChildren) => {
{
estimate: estimate,
userId: userId,
username: userData?.name ?? '',
},
]),
});

View File

@ -11,22 +11,17 @@ import {
databases,
ESTIMATION_SESSION_COLLECTION_ID,
} from '../appwrite';
import { ID, Models, Query } from 'appwrite';
import { EntityModels } from '../types';
import { mapDatabaseToEntity } from '../mappers/estimationSession';
interface EstimationSessionType extends Models.Document {
userId: string;
name: string;
tickets: string[];
sessionState: string;
}
import { ID, Query } from 'appwrite';
import { DatabaseModels, EntityModels } from '../types';
import {
mapDatabaseToEntity,
mapEntityToDatabase,
} from '../mappers/estimationSession';
import { useUser } from './user';
interface EstimationsListContextType {
current: EntityModels.EstimationSession[];
add: (
estimationSession: Omit<EstimationSessionType, keyof Models.Document>,
) => Promise<void>;
add: (estimationSession: { name: string; userId?: string }) => Promise<void>;
remove: (id: string) => Promise<void>;
}
@ -39,19 +34,38 @@ export function useEstimationsList() {
}
export function EstimationsListContextProvider(props: PropsWithChildren) {
const { current: userData } = useUser();
const [estimationSessions, setEstimationSessions] = useState<
EntityModels.EstimationSession[]
>([]);
const add = async (
estimationSession: Omit<EstimationSessionType, keyof Models.Document>,
) => {
const response = await databases.createDocument<EstimationSessionType>(
DATABASE_ID,
ESTIMATION_SESSION_COLLECTION_ID,
ID.unique(),
estimationSession,
);
const add = async (estimationSession: { name: string; userId?: string }) => {
if (!userData) {
throw Error('Tried to create new estimation with no user context');
}
const username =
userData.name.length > 0 ? userData.name : `Guest - ${userData.$id}`;
const newEstimationSession: Partial<EntityModels.EstimationSession> = {
name: estimationSession.name,
userId: userData.$id,
playerIds: [userData.$id],
players: [
{
userId: userData.$id,
name: username,
},
],
};
const response =
await databases.createDocument<DatabaseModels.EstimationSession>(
DATABASE_ID,
ESTIMATION_SESSION_COLLECTION_ID,
ID.unique(),
mapEntityToDatabase(newEstimationSession),
);
setEstimationSessions((estimationSessions) =>
[mapDatabaseToEntity(response, {}), ...estimationSessions].slice(0, 10),
);
@ -72,11 +86,12 @@ export function EstimationsListContextProvider(props: PropsWithChildren) {
};
const init = async () => {
const response = await databases.listDocuments<EstimationSessionType>(
DATABASE_ID,
ESTIMATION_SESSION_COLLECTION_ID,
[Query.orderDesc('$createdAt'), Query.limit(10)],
);
const response =
await databases.listDocuments<DatabaseModels.EstimationSession>(
DATABASE_ID,
ESTIMATION_SESSION_COLLECTION_ID,
[Query.orderDesc('$createdAt'), Query.limit(10)],
);
setEstimationSessions(
response.documents.map((document) => mapDatabaseToEntity(document, {})),
);
@ -85,7 +100,7 @@ export function EstimationsListContextProvider(props: PropsWithChildren) {
useEffect(() => {
init();
return client.subscribe<EstimationSessionType>(
return client.subscribe<DatabaseModels.EstimationSession>(
[
`databases.${DATABASE_ID}.collections.${ESTIMATION_SESSION_COLLECTION_ID}.documents`,
],

View File

@ -9,12 +9,13 @@ import {
import { account } from '../appwrite';
export interface UserContextType {
current: Models.Session | Models.User<Models.Preferences> | null;
current: Models.User<Models.Preferences> | null;
isLoading: boolean;
login: (email: string, password: string) => Promise<void>;
logout: () => Promise<void>;
register: (email: string, password: string) => Promise<void>;
loginAsGuest: () => Promise<void>;
updateUsername: (username: string) => Promise<void>;
}
const UserContext = createContext<UserContextType | undefined>(undefined);
@ -28,14 +29,15 @@ export const useUser = () => {
};
export const UserProvider = (props: PropsWithChildren) => {
const [user, setUser] = useState<
Models.Session | Models.User<Models.Preferences> | null
>(null);
const [user, setUser] = useState<Models.User<Models.Preferences> | null>(
null,
);
const [isLoading, setIsLoading] = useState(true);
const login = async (email: string, password: string) => {
const loggedIn = await account.createEmailPasswordSession(email, password);
setUser(loggedIn);
await account.createEmailPasswordSession(email, password);
const userData = await account.get();
setUser(userData);
const params = new URLSearchParams(window.location.search);
const redirectPath = params.get('redirect');
@ -54,14 +56,20 @@ export const UserProvider = (props: PropsWithChildren) => {
};
const loginAsGuest = async () => {
const session = await account.createAnonymousSession();
setUser(session);
await account.createAnonymousSession();
const userData = await account.get();
setUser(userData);
const params = new URLSearchParams(window.location.search);
const redirectPath = params.get('redirect');
window.location.replace(redirectPath || '/');
};
const updateUsername = async (username: string) => {
const user = await account.updateName(username);
setUser(user);
};
const init = async () => {
try {
const loggedIn = await account.get();
@ -86,6 +94,7 @@ export const UserProvider = (props: PropsWithChildren) => {
logout,
register,
loginAsGuest,
updateUsername,
}}
>
{props.children}

View File

@ -16,13 +16,26 @@ export const mapDatabaseToEntity = (
)
: [];
const players = data.players
? data.players.map<EntityModels.Player>((user) => JSON.parse(user))
: [];
const votes = sessionState.votes.map<EntityModels.PlayerVote>((vote) => ({
...vote,
username:
players.find((x) => x.userId === vote.userId)?.name ?? vote.userId,
}));
const result: EntityModels.EstimationSession = {
id: data.$id,
name: data.name,
userId: data.userId,
tickets,
players,
playerIds: data.playerIds,
sessionState: {
...sessionState,
votes,
currentPlayerVote: sessionState.votes.find((x) => x.userId === userId)
?.estimate,
currentTicket: tickets.find((x) => x.id === sessionState.currentTicketId),
@ -31,3 +44,19 @@ export const mapDatabaseToEntity = (
return result;
};
export const mapEntityToDatabase = (
data: Partial<EntityModels.EstimationSession>,
) => {
const result: Partial<DatabaseModels.EstimationSession> = {
$id: data.id,
name: data.name,
userId: data.userId,
tickets: data.tickets?.map((ticket) => JSON.stringify(ticket)),
playerIds: data.playerIds,
players: data.players?.map((player) => JSON.stringify(player)),
sessionState: JSON.stringify(data.sessionState),
};
return result;
};

View File

@ -5,6 +5,8 @@ interface EstimationSession extends Models.Document {
name: string;
tickets: string[];
sessionState: string;
players: string[];
playerIds: string[];
}
export type { EstimationSession };

View File

@ -4,6 +4,8 @@ interface EstimationSession {
name: string;
tickets: EstimationSessionTicket[];
sessionState: SessionState;
players: Player[];
playerIds: string[];
}
interface EstimationSessionTicket {
@ -21,12 +23,19 @@ interface SessionState {
interface PlayerVote {
userId: string;
username: string;
estimate: string;
}
interface Player {
userId: string;
name: string;
}
export type {
EstimationSession,
EstimationSessionTicket,
Player,
SessionState,
PlayerVote,
};

View File

@ -7,6 +7,7 @@ import VoteList from './components/VoteList';
import { Button, ButtonColor, Drawer } from '../../components';
import CreateTicketForm from './components/CreateTicketForm';
import CopyInput from '../../components/CopyInput';
import PlayerList from './components/PlayerList';
const fibonacciSequence = [1, 2, 3, 5, 8, 13, 21, 34, 55, 89, 100];
@ -35,6 +36,7 @@ const Estimation: React.FC = () => {
currentPlayerVote,
currentTicket,
},
players,
},
} = estimationState;
@ -84,6 +86,8 @@ const Estimation: React.FC = () => {
)}
</div>
<PlayerList players={players ?? []} />
<Drawer isOpen={isDrawerOpen} onClose={() => setDrawerOpen(false)}>
<CreateTicketForm
onCreate={async (ticket) => {

View File

@ -0,0 +1,39 @@
import React from 'react';
import { EntityModels } from '../../../lib/types';
interface PlayerListProps {
players: EntityModels.Player[];
title?: string;
}
const PlayerList: React.FC<PlayerListProps> = ({
players,
title = 'Players',
}) => {
return (
<div className="w-full max-w-sm rounded-lg bg-white p-6 shadow-lg dark:bg-nero-800">
<h2 className="mb-4 text-xl font-semibold text-gray-900 dark:text-gray-100">
{title}
</h2>
<ul className="max-h-48 divide-y divide-gray-300 overflow-y-auto dark:divide-gray-600">
{players.length > 0 ? (
players.map((player, index) => (
<li
key={index}
className="py-2 text-sm text-gray-900 dark:text-gray-100"
>
{player.name}
</li>
))
) : (
<li className="text-sm text-gray-500 dark:text-gray-400">
No players available
</li>
)}
</ul>
</div>
);
};
export default PlayerList;

View File

@ -16,7 +16,7 @@ const VoteList: React.FC<VoteListProps> = ({ className, votes, revealed }) => {
itemComponent={({ item }, idx) => (
<Card
key={idx}
title={item.userId}
title={item.username}
description={revealed ? item.estimate : 'Hidden'}
/>
)}

View File

@ -1,5 +1,52 @@
import { useForm } from '@tanstack/react-form';
import { Button, Input } from '../components';
import { useUser } from '../lib/context/user';
const Profile = () => {
return <p>TODO</p>;
const user = useUser();
const updateUsernameForm = useForm({
defaultValues: {
name: '',
},
onSubmit: async ({ value }) => {
await user.updateUsername(value.name);
updateUsernameForm.reset();
},
});
return (
<div className="flex min-h-screen items-center justify-center bg-gray-50 transition-colors dark:bg-nero-900">
<div className="w-full max-w-md rounded-lg bg-white p-8 shadow-lg dark:bg-nero-800">
<h1 className="mb-6 text-2xl font-semibold text-gray-900 dark:text-gray-100">
Update Name
</h1>
<form
className="space-y-6"
onSubmit={(e) => {
e.preventDefault();
e.stopPropagation();
updateUsernameForm.handleSubmit();
}}
>
<updateUsernameForm.Field
name="name"
children={(field) => (
<Input
label="Name"
name={field.name}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
/>
)}
/>
<Button type="submit" fullWidth>
Update
</Button>
</form>
</div>
</div>
);
};
export default Profile;