Implement basic team features
							parent
							
								
									2fda11bc9a
								
							
						
					
					
						commit
						050a012318
					
				| 
						 | 
				
			
			@ -35,9 +35,12 @@
 | 
			
		|||
  --mantle: #e6e9ef;
 | 
			
		||||
  --crust: #dce0e8;
 | 
			
		||||
 | 
			
		||||
  --flamingo: #f0c6c6;
 | 
			
		||||
  --red: #d20f39;
 | 
			
		||||
 | 
			
		||||
  --flamingo: #dd7878;
 | 
			
		||||
  --flamingo-transparent: #f0c6c655;
 | 
			
		||||
  --green: #40a02b;
 | 
			
		||||
  --peach: #fe640b;
 | 
			
		||||
  --yellow: #df8e1d;
 | 
			
		||||
  --lavender: #7287fd;
 | 
			
		||||
  --accent: var(--lavender);
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -70,6 +70,12 @@ button.accent.dark {
 | 
			
		|||
  color: var(--base);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
button.destructive {
 | 
			
		||||
  background-color: var(--flamingo);
 | 
			
		||||
  color: var(--base);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
button.accent:hover {
 | 
			
		||||
  background-color: var(--text);
 | 
			
		||||
  color: var(--base);
 | 
			
		||||
| 
						 | 
				
			
			@ -108,10 +114,14 @@ h1 {
 | 
			
		|||
}
 | 
			
		||||
 | 
			
		||||
h2 {
 | 
			
		||||
  font-weight: 700;
 | 
			
		||||
  font-weight: 800;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
em.aside {
 | 
			
		||||
span.small {
 | 
			
		||||
  font-size: 9pt;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
em.aside, span.aside {
 | 
			
		||||
  color: var(--overlay-0);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -15,6 +15,8 @@ export type { CreateTeamJson } from './models/CreateTeamJson';
 | 
			
		|||
export type { EditMemberRolesJson } from './models/EditMemberRolesJson';
 | 
			
		||||
export type { PutScheduleForm } from './models/PutScheduleForm';
 | 
			
		||||
export type { RoleSchema } from './models/RoleSchema';
 | 
			
		||||
export type { TeamInviteSchema } from './models/TeamInviteSchema';
 | 
			
		||||
export type { TeamInviteSchemaList } from './models/TeamInviteSchemaList';
 | 
			
		||||
export { TeamRole } from './models/TeamRole';
 | 
			
		||||
export type { TeamSchema } from './models/TeamSchema';
 | 
			
		||||
export type { ValidationError } from './models/ValidationError';
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,10 @@
 | 
			
		|||
/* generated using openapi-typescript-codegen -- do not edit */
 | 
			
		||||
/* istanbul ignore file */
 | 
			
		||||
/* tslint:disable */
 | 
			
		||||
/* eslint-disable */
 | 
			
		||||
export type TeamInviteSchema = {
 | 
			
		||||
    createdAt: string;
 | 
			
		||||
    key: string;
 | 
			
		||||
    teamId: number;
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -0,0 +1,6 @@
 | 
			
		|||
/* generated using openapi-typescript-codegen -- do not edit */
 | 
			
		||||
/* istanbul ignore file */
 | 
			
		||||
/* tslint:disable */
 | 
			
		||||
/* eslint-disable */
 | 
			
		||||
import type { TeamInviteSchema } from './TeamInviteSchema';
 | 
			
		||||
export type TeamInviteSchemaList = Array<TeamInviteSchema>;
 | 
			
		||||
| 
						 | 
				
			
			@ -6,6 +6,8 @@ import type { AddPlayerJson } from '../models/AddPlayerJson';
 | 
			
		|||
import type { CreateTeamJson } from '../models/CreateTeamJson';
 | 
			
		||||
import type { EditMemberRolesJson } from '../models/EditMemberRolesJson';
 | 
			
		||||
import type { PutScheduleForm } from '../models/PutScheduleForm';
 | 
			
		||||
import type { TeamInviteSchema } from '../models/TeamInviteSchema';
 | 
			
		||||
import type { TeamInviteSchemaList } from '../models/TeamInviteSchemaList';
 | 
			
		||||
import type { ViewScheduleResponse } from '../models/ViewScheduleResponse';
 | 
			
		||||
import type { ViewTeamMembersResponseList } from '../models/ViewTeamMembersResponseList';
 | 
			
		||||
import type { ViewTeamResponse } from '../models/ViewTeamResponse';
 | 
			
		||||
| 
						 | 
				
			
			@ -211,6 +213,30 @@ export class DefaultService {
 | 
			
		|||
            },
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
    /**
 | 
			
		||||
     * consume_invite <POST>
 | 
			
		||||
     * @param teamId
 | 
			
		||||
     * @param key
 | 
			
		||||
     * @returns void
 | 
			
		||||
     * @throws ApiError
 | 
			
		||||
     */
 | 
			
		||||
    public consumeInvite(
 | 
			
		||||
        teamId: string,
 | 
			
		||||
        key: string,
 | 
			
		||||
    ): CancelablePromise<void> {
 | 
			
		||||
        return this.httpRequest.request({
 | 
			
		||||
            method: 'POST',
 | 
			
		||||
            url: '/api/team/id/{team_id}/consume-invite/{key}',
 | 
			
		||||
            path: {
 | 
			
		||||
                'team_id': teamId,
 | 
			
		||||
                'key': key,
 | 
			
		||||
            },
 | 
			
		||||
            errors: {
 | 
			
		||||
                404: `Not Found`,
 | 
			
		||||
                422: `Unprocessable Entity`,
 | 
			
		||||
            },
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
    /**
 | 
			
		||||
     * edit_member_roles <PATCH>
 | 
			
		||||
     * @param teamId
 | 
			
		||||
| 
						 | 
				
			
			@ -240,6 +266,72 @@ export class DefaultService {
 | 
			
		|||
            },
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
    /**
 | 
			
		||||
     * get_invites <GET>
 | 
			
		||||
     * @param teamId
 | 
			
		||||
     * @returns TeamInviteSchemaList OK
 | 
			
		||||
     * @throws ApiError
 | 
			
		||||
     */
 | 
			
		||||
    public getInvites(
 | 
			
		||||
        teamId: string,
 | 
			
		||||
    ): CancelablePromise<TeamInviteSchemaList> {
 | 
			
		||||
        return this.httpRequest.request({
 | 
			
		||||
            method: 'GET',
 | 
			
		||||
            url: '/api/team/id/{team_id}/invite',
 | 
			
		||||
            path: {
 | 
			
		||||
                'team_id': teamId,
 | 
			
		||||
            },
 | 
			
		||||
            errors: {
 | 
			
		||||
                404: `Not Found`,
 | 
			
		||||
                422: `Unprocessable Entity`,
 | 
			
		||||
            },
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
    /**
 | 
			
		||||
     * create_invite <POST>
 | 
			
		||||
     * @param teamId
 | 
			
		||||
     * @returns TeamInviteSchema OK
 | 
			
		||||
     * @throws ApiError
 | 
			
		||||
     */
 | 
			
		||||
    public createInvite(
 | 
			
		||||
        teamId: string,
 | 
			
		||||
    ): CancelablePromise<TeamInviteSchema> {
 | 
			
		||||
        return this.httpRequest.request({
 | 
			
		||||
            method: 'POST',
 | 
			
		||||
            url: '/api/team/id/{team_id}/invite',
 | 
			
		||||
            path: {
 | 
			
		||||
                'team_id': teamId,
 | 
			
		||||
            },
 | 
			
		||||
            errors: {
 | 
			
		||||
                404: `Not Found`,
 | 
			
		||||
                422: `Unprocessable Entity`,
 | 
			
		||||
            },
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
    /**
 | 
			
		||||
     * revoke_invite <DELETE>
 | 
			
		||||
     * @param teamId
 | 
			
		||||
     * @param key
 | 
			
		||||
     * @returns void
 | 
			
		||||
     * @throws ApiError
 | 
			
		||||
     */
 | 
			
		||||
    public revokeInvite(
 | 
			
		||||
        teamId: string,
 | 
			
		||||
        key: string,
 | 
			
		||||
    ): CancelablePromise<void> {
 | 
			
		||||
        return this.httpRequest.request({
 | 
			
		||||
            method: 'DELETE',
 | 
			
		||||
            url: '/api/team/id/{team_id}/invite/{key}',
 | 
			
		||||
            path: {
 | 
			
		||||
                'team_id': teamId,
 | 
			
		||||
                'key': key,
 | 
			
		||||
            },
 | 
			
		||||
            errors: {
 | 
			
		||||
                404: `Not Found`,
 | 
			
		||||
                422: `Unprocessable Entity`,
 | 
			
		||||
            },
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
    /**
 | 
			
		||||
     * add_player <PUT>
 | 
			
		||||
     * @param teamId
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,83 @@
 | 
			
		|||
<script setup lang="ts">
 | 
			
		||||
import { type TeamInviteSchema } from "../client";
 | 
			
		||||
import { useTeamsStore } from "../stores/teams";
 | 
			
		||||
import { computed } from "vue";
 | 
			
		||||
 | 
			
		||||
const teamsStore = useTeamsStore();
 | 
			
		||||
 | 
			
		||||
const props = defineProps({
 | 
			
		||||
  invite: Object as PropType<TeamInviteSchema>,
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const inviteLink = computed(() => {
 | 
			
		||||
  let teamId = props.invite.teamId;
 | 
			
		||||
  let key = props.invite.key;
 | 
			
		||||
  return `${window.location.origin}/team/id/${teamId}?key=${key}`;
 | 
			
		||||
})
 | 
			
		||||
 | 
			
		||||
function copyLink() {
 | 
			
		||||
  navigator.clipboard.writeText(inviteLink.value);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function revokeInvite() {
 | 
			
		||||
  teamsStore.revokeInvite(props.invite.teamId, props.invite.key);
 | 
			
		||||
}
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
<template>
 | 
			
		||||
  <tr>
 | 
			
		||||
    <td>
 | 
			
		||||
      <a class="key" :href="inviteLink">
 | 
			
		||||
        <code>
 | 
			
		||||
          {{ invite.key }}
 | 
			
		||||
        </code>
 | 
			
		||||
      </a>
 | 
			
		||||
    </td>
 | 
			
		||||
    <td>
 | 
			
		||||
      {{ invite.createdAt }}
 | 
			
		||||
    </td>
 | 
			
		||||
    <td class="buttons">
 | 
			
		||||
      <button @click="copyLink">
 | 
			
		||||
        <i class="bi bi-link margin" />
 | 
			
		||||
        Copy Link
 | 
			
		||||
      </button>
 | 
			
		||||
      <button class="destructive" @click="revokeInvite">
 | 
			
		||||
        <i class="bi bi-trash margin" />
 | 
			
		||||
        Revoke
 | 
			
		||||
      </button>
 | 
			
		||||
    </td>
 | 
			
		||||
  </tr>
 | 
			
		||||
</template>
 | 
			
		||||
 | 
			
		||||
<style scoped>
 | 
			
		||||
tr .buttons {
 | 
			
		||||
  opacity: 0;
 | 
			
		||||
  transition-duration: 200ms;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
tr:hover .buttons {
 | 
			
		||||
  opacity: 1;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
td {
 | 
			
		||||
  padding: 8px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.key {
 | 
			
		||||
  color: var(--text);
 | 
			
		||||
  background-color: var(--text);
 | 
			
		||||
  padding: 2px 4px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.key:hover {
 | 
			
		||||
  color: var(--base);
 | 
			
		||||
  transition-duration: 200ms;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.buttons {
 | 
			
		||||
  display: flex;
 | 
			
		||||
  align-content: center;
 | 
			
		||||
  justify-content: end;
 | 
			
		||||
  gap: 8px;
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
| 
						 | 
				
			
			@ -75,7 +75,10 @@ function updateRoles() {
 | 
			
		|||
  <tr class="player-card">
 | 
			
		||||
    <td>
 | 
			
		||||
      <div class="status flex-middle" :availability="player.availability">
 | 
			
		||||
        <span class="dot"></span>
 | 
			
		||||
        <div class="status-indicators">
 | 
			
		||||
          <span class="indicator left-indicator" />
 | 
			
		||||
          <span class="indicator right-indicator" />
 | 
			
		||||
        </div>
 | 
			
		||||
        <h3>
 | 
			
		||||
          {{ player.username }}
 | 
			
		||||
        </h3>
 | 
			
		||||
| 
						 | 
				
			
			@ -129,8 +132,6 @@ function updateRoles() {
 | 
			
		|||
  user-select: none;
 | 
			
		||||
  gap: 1em;
 | 
			
		||||
  align-items: center;
 | 
			
		||||
  border: 2px solid white;
 | 
			
		||||
  box-shadow: 1px 1px 8px var(--surface-0);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.player-card > td {
 | 
			
		||||
| 
						 | 
				
			
			@ -142,24 +143,37 @@ function updateRoles() {
 | 
			
		|||
  font-size: 12pt;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.dot {
 | 
			
		||||
.status-indicators {
 | 
			
		||||
  display: flex;
 | 
			
		||||
  flex-direction: row;
 | 
			
		||||
  gap: 2px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.status-indicators > .indicator {
 | 
			
		||||
  display: block;
 | 
			
		||||
  border-radius: 50%;
 | 
			
		||||
  height: 8px;
 | 
			
		||||
  width: 8px;
 | 
			
		||||
  width: 12px;
 | 
			
		||||
  background-color: var(--overlay-0);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.left-indicator {
 | 
			
		||||
  border-radius: 8px 0 0 8px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.right-indicator {
 | 
			
		||||
  border-radius: 0 8px 8px 0;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.status[availability="0"] h3 {
 | 
			
		||||
  color: var(--overlay-0);
 | 
			
		||||
  font-weight: 400;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.status[availability="1"] .dot {
 | 
			
		||||
.status[availability="1"] .indicator {
 | 
			
		||||
  background-color: var(--yellow);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.status[availability="2"] .dot {
 | 
			
		||||
.status[availability="2"] .indicator {
 | 
			
		||||
  background-color: var(--green);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,17 +1,18 @@
 | 
			
		|||
import Cacheable from "@/cacheable";
 | 
			
		||||
import { AvailabilitfClient, type RoleSchema, type TeamSpec, type ViewTeamMembersResponse, type ViewTeamResponse, type ViewTeamsResponse } from "@/client";
 | 
			
		||||
import { AvailabilitfClient, type TeamInviteSchema, type RoleSchema, type TeamSchema, type ViewTeamMembersResponse, type ViewTeamResponse, type ViewTeamsResponse } from "@/client";
 | 
			
		||||
import { defineStore } from "pinia";
 | 
			
		||||
import { computed, reactive, ref, type Reactive, type Ref } from "vue";
 | 
			
		||||
import { useClientStore } from "./client";
 | 
			
		||||
 | 
			
		||||
export type TeamMap = { [id: number]: TeamSpec };
 | 
			
		||||
export type TeamMap = { [id: number]: TeamSchema };
 | 
			
		||||
 | 
			
		||||
export const useTeamsStore = defineStore("teams", () => {
 | 
			
		||||
  const clientStore = useClientStore();
 | 
			
		||||
  const client = clientStore.client;
 | 
			
		||||
 | 
			
		||||
  const teams: Reactive<{ [id: number]: TeamSpec }> = reactive({ });
 | 
			
		||||
  const teams: Reactive<{ [id: number]: TeamSchema }> = reactive({ });
 | 
			
		||||
  const teamMembers: Reactive<{ [id: number]: ViewTeamMembersResponse[] }> = reactive({ });
 | 
			
		||||
  const teamInvites: Reactive<{ [id: number]: TeamInviteSchema[] }> = reactive({ });
 | 
			
		||||
 | 
			
		||||
  const isFetchingTeams = ref(false);
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -78,13 +79,55 @@ export const useTeamsStore = defineStore("teams", () => {
 | 
			
		|||
      });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async function getInvites(teamId: number) {
 | 
			
		||||
    return clientStore.call(
 | 
			
		||||
      getInvites.name,
 | 
			
		||||
      () => client.default.getInvites(teamId.toString()),
 | 
			
		||||
      (response) => {
 | 
			
		||||
        teamInvites[teamId] = response;
 | 
			
		||||
        return response;
 | 
			
		||||
      }
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async function createInvite(teamId: number) {
 | 
			
		||||
    return client.default.createInvite(teamId.toString())
 | 
			
		||||
      .then((response) => {
 | 
			
		||||
        teamInvites[teamId].push(response);
 | 
			
		||||
        return response;
 | 
			
		||||
      })
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async function consumeInvite(teamId: number, key: string) {
 | 
			
		||||
    return client.default.consumeInvite(teamId.toString(), key)
 | 
			
		||||
      .then((response) => {
 | 
			
		||||
        teamInvites[teamId] = teamInvites[teamId]
 | 
			
		||||
          .filter((invite) => invite.key != key);
 | 
			
		||||
        return response;
 | 
			
		||||
      });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async function revokeInvite(teamId: number, key: string) {
 | 
			
		||||
    return client.default.revokeInvite(teamId.toString(), key)
 | 
			
		||||
      .then((response) => {
 | 
			
		||||
        teamInvites[teamId] = teamInvites[teamId]
 | 
			
		||||
          .filter((invite) => invite.key != key);
 | 
			
		||||
        return response;
 | 
			
		||||
      });
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  return {
 | 
			
		||||
    teams,
 | 
			
		||||
    teamInvites,
 | 
			
		||||
    teamMembers,
 | 
			
		||||
    fetchTeams,
 | 
			
		||||
    fetchTeam,
 | 
			
		||||
    fetchTeamMembers,
 | 
			
		||||
    createTeam,
 | 
			
		||||
    updateRoles
 | 
			
		||||
    updateRoles,
 | 
			
		||||
    getInvites,
 | 
			
		||||
    createInvite,
 | 
			
		||||
    consumeInvite,
 | 
			
		||||
    revokeInvite,
 | 
			
		||||
  };
 | 
			
		||||
});
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,8 +1,9 @@
 | 
			
		|||
<script setup lang="ts">
 | 
			
		||||
import { useRoute, useRouter, RouterLink } from "vue-router";
 | 
			
		||||
import { useTeamsStore } from "../stores/teams";
 | 
			
		||||
import { computed, onMounted } from "vue";
 | 
			
		||||
import { computed, onMounted, ref } from "vue";
 | 
			
		||||
import PlayerTeamCard from "../components/PlayerTeamCard.vue";
 | 
			
		||||
import InviteEntry from "../components/InviteEntry.vue";
 | 
			
		||||
 | 
			
		||||
const route = useRoute();
 | 
			
		||||
const router = useRouter();
 | 
			
		||||
| 
						 | 
				
			
			@ -12,14 +13,34 @@ const team = computed(() => {
 | 
			
		|||
  return teamsStore.teams[route.params.id];
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const invites = computed(() => {
 | 
			
		||||
  return teamsStore.teamInvites[route.params.id];
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const availableMembers = computed(() => {
 | 
			
		||||
  return teamsStore.teamMembers[route.params.id]
 | 
			
		||||
    .filter((member) => member.availability > 0);
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
onMounted(() => {
 | 
			
		||||
  teamsStore.fetchTeam(route.params.id)
 | 
			
		||||
    .then(() => teamsStore.fetchTeamMembers(route.params.id));
 | 
			
		||||
function createInvite() {
 | 
			
		||||
  teamsStore.createInvite(team.value.id);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function revokeInvite(key) {
 | 
			
		||||
  teamsStore.revokeInvite(team.value.id, key)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
onMounted(async () => {
 | 
			
		||||
  let key = route.query.key;
 | 
			
		||||
  let teamId = route.params.id;
 | 
			
		||||
 | 
			
		||||
  if (key) {
 | 
			
		||||
    await teamsStore.consumeInvite(teamId, key);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  teamsStore.fetchTeam(teamId)
 | 
			
		||||
    .then(() => teamsStore.fetchTeamMembers(teamId))
 | 
			
		||||
    .then(() => teamsStore.getInvites(teamId));
 | 
			
		||||
});
 | 
			
		||||
</script>
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -29,15 +50,17 @@ onMounted(() => {
 | 
			
		|||
      <h1>
 | 
			
		||||
        {{ team.teamName }}
 | 
			
		||||
        <RouterLink :to="'/schedule?teamId=' + team.id">
 | 
			
		||||
          <button class="accent">
 | 
			
		||||
            <i class="bi bi-calendar-fill margin"></i>
 | 
			
		||||
            View schedule
 | 
			
		||||
          </button>
 | 
			
		||||
        </RouterLink>
 | 
			
		||||
        <em class="aside" v-if="teamsStore.teamMembers[route.params.id]">
 | 
			
		||||
          {{ teamsStore.teamMembers[route.params.id]?.length }} member(s),
 | 
			
		||||
          {{ availableMembers?.length }} currently available
 | 
			
		||||
        </em>
 | 
			
		||||
        <div class="team-details-button-group">
 | 
			
		||||
          <button class="accent">
 | 
			
		||||
            <i class="bi bi-calendar-fill margin"></i>
 | 
			
		||||
            View schedule
 | 
			
		||||
          </button>
 | 
			
		||||
        </div>
 | 
			
		||||
      </h1>
 | 
			
		||||
      <table class="member-table">
 | 
			
		||||
        <!--thead>
 | 
			
		||||
| 
						 | 
				
			
			@ -65,6 +88,42 @@ onMounted(() => {
 | 
			
		|||
          />
 | 
			
		||||
        </tbody>
 | 
			
		||||
      </table>
 | 
			
		||||
      <h2>Active Invites</h2>
 | 
			
		||||
      <div>
 | 
			
		||||
        <details>
 | 
			
		||||
          <summary>View all invites</summary>
 | 
			
		||||
          <span v-if="invites?.length == 0">
 | 
			
		||||
            There are currently no active invites to this team.
 | 
			
		||||
          </span>
 | 
			
		||||
          <table id="invite-table" v-else>
 | 
			
		||||
            <thead>
 | 
			
		||||
              <tr>
 | 
			
		||||
                <th>
 | 
			
		||||
                  Key (hover to reveal)
 | 
			
		||||
                </th>
 | 
			
		||||
                <th>
 | 
			
		||||
                  Creation time
 | 
			
		||||
                </th>
 | 
			
		||||
              </tr>
 | 
			
		||||
            </thead>
 | 
			
		||||
            <tbody>
 | 
			
		||||
              <InviteEntry
 | 
			
		||||
                v-for="invite in invites"
 | 
			
		||||
                :invite="invite"
 | 
			
		||||
              />
 | 
			
		||||
            </tbody>
 | 
			
		||||
          </table>
 | 
			
		||||
          <div class="create-invite-group">
 | 
			
		||||
            <button class="accent" @click="createInvite">
 | 
			
		||||
              <i class="bi bi-person-fill-add margin" />
 | 
			
		||||
              Create Invite
 | 
			
		||||
            </button>
 | 
			
		||||
            <span class="small aside">
 | 
			
		||||
              Invites are usable once and expire after 24 hours.
 | 
			
		||||
            </span>
 | 
			
		||||
          </div>
 | 
			
		||||
        </details>
 | 
			
		||||
      </div>
 | 
			
		||||
    </template>
 | 
			
		||||
  </main>
 | 
			
		||||
</template>
 | 
			
		||||
| 
						 | 
				
			
			@ -98,4 +157,29 @@ div.member-grid {
 | 
			
		|||
  flex-wrap: wrap;
 | 
			
		||||
}
 | 
			
		||||
*/
 | 
			
		||||
 | 
			
		||||
th {
 | 
			
		||||
  text-align: left;
 | 
			
		||||
  font-weight: 600;
 | 
			
		||||
  padding: 8px;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
#invite-table {
 | 
			
		||||
  width: 100%;
 | 
			
		||||
  border: 1px solid var(--text);
 | 
			
		||||
  margin: 8px 0;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.team-details-button-group {
 | 
			
		||||
  flex: 1;
 | 
			
		||||
  display: flex;
 | 
			
		||||
  align-items: center;
 | 
			
		||||
  justify-content: end;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.create-invite-group {
 | 
			
		||||
  display: flex;
 | 
			
		||||
  gap: 8px;
 | 
			
		||||
  align-items: center;
 | 
			
		||||
}
 | 
			
		||||
</style>
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -0,0 +1,35 @@
 | 
			
		|||
"""Add TeamInvite
 | 
			
		||||
 | 
			
		||||
Revision ID: 65714d7e78f8
 | 
			
		||||
Revises: f50a79c4ae22
 | 
			
		||||
Create Date: 2024-11-08 23:16:04.669526
 | 
			
		||||
 | 
			
		||||
"""
 | 
			
		||||
from alembic import op
 | 
			
		||||
import sqlalchemy as sa
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
# revision identifiers, used by Alembic.
 | 
			
		||||
revision = '65714d7e78f8'
 | 
			
		||||
down_revision = 'f50a79c4ae22'
 | 
			
		||||
branch_labels = None
 | 
			
		||||
depends_on = None
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def upgrade():
 | 
			
		||||
    # ### commands auto generated by Alembic - please adjust! ###
 | 
			
		||||
    op.create_table('team_invites',
 | 
			
		||||
    sa.Column('key', sa.String(length=31), nullable=False),
 | 
			
		||||
    sa.Column('team_id', sa.Integer(), nullable=False),
 | 
			
		||||
    sa.Column('created_at', sa.TIMESTAMP(), server_default=sa.text('(CURRENT_TIMESTAMP)'), nullable=False),
 | 
			
		||||
    sa.Column('delete_on_use', sa.Boolean(), nullable=False),
 | 
			
		||||
    sa.ForeignKeyConstraint(['team_id'], ['teams.id'], ),
 | 
			
		||||
    sa.PrimaryKeyConstraint('key')
 | 
			
		||||
    )
 | 
			
		||||
    # ### end Alembic commands ###
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def downgrade():
 | 
			
		||||
    # ### commands auto generated by Alembic - please adjust! ###
 | 
			
		||||
    op.drop_table('team_invites')
 | 
			
		||||
    # ### end Alembic commands ###
 | 
			
		||||
| 
						 | 
				
			
			@ -50,6 +50,7 @@ class Team(db.Model):
 | 
			
		|||
    minute_offset: Mapped[int] = mapped_column(SmallInteger, default=0)
 | 
			
		||||
 | 
			
		||||
    players: Mapped[List["PlayerTeam"]] = relationship(back_populates="team")
 | 
			
		||||
    invites: Mapped[List["TeamInvite"]] = relationship(back_populates="team")
 | 
			
		||||
 | 
			
		||||
    created_at: Mapped[datetime] = mapped_column(TIMESTAMP, server_default=func.now())
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			@ -145,6 +146,21 @@ class PlayerTeamAvailability(db.Model):
 | 
			
		|||
        ),
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
class TeamInvite(db.Model):
 | 
			
		||||
    __tablename__ = "team_invites"
 | 
			
		||||
 | 
			
		||||
    key: Mapped[str] = mapped_column(String(31), primary_key=True)
 | 
			
		||||
    team_id: Mapped[int] = mapped_column(ForeignKey("teams.id"))
 | 
			
		||||
    delete_on_use: Mapped[bool] = mapped_column(Boolean, default=True)
 | 
			
		||||
    created_at: Mapped[datetime] = mapped_column(TIMESTAMP, server_default=func.now())
 | 
			
		||||
 | 
			
		||||
    team: Mapped["Team"] = relationship(back_populates="invites")
 | 
			
		||||
 | 
			
		||||
class TeamInviteSchema(spec.BaseModel):
 | 
			
		||||
    key: str
 | 
			
		||||
    team_id: int
 | 
			
		||||
    created_at: datetime
 | 
			
		||||
 | 
			
		||||
class AuthSession(db.Model):
 | 
			
		||||
    __tablename__ = "auth_sessions"
 | 
			
		||||
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
| 
						 | 
				
			
			@ -1,11 +1,13 @@
 | 
			
		|||
import datetime
 | 
			
		||||
from datetime import datetime, timezone
 | 
			
		||||
from random import randint, random
 | 
			
		||||
import sys
 | 
			
		||||
import time
 | 
			
		||||
from typing import List
 | 
			
		||||
from flask import Blueprint, abort, jsonify, make_response, request
 | 
			
		||||
from pydantic.v1 import validator
 | 
			
		||||
from spectree import Response
 | 
			
		||||
from sqlalchemy.orm import joinedload, subqueryload
 | 
			
		||||
from models import Player, PlayerSchema, PlayerTeam, PlayerTeamAvailability, PlayerTeamRole, PlayerTeamSchema, Team, TeamSchema, db
 | 
			
		||||
from models import Player, PlayerSchema, PlayerTeam, PlayerTeamAvailability, PlayerTeamRole, PlayerTeamSchema, Team, TeamInvite, TeamInviteSchema, TeamSchema, db
 | 
			
		||||
from middleware import requires_authentication
 | 
			
		||||
import models
 | 
			
		||||
from spec import spec, BaseModel
 | 
			
		||||
| 
						 | 
				
			
			@ -238,7 +240,7 @@ class ViewTeamMembersResponse(PlayerSchema):
 | 
			
		|||
    roles: list[RoleSchema]
 | 
			
		||||
    availability: int
 | 
			
		||||
    playtime: float
 | 
			
		||||
    created_at: datetime.datetime
 | 
			
		||||
    created_at: datetime
 | 
			
		||||
 | 
			
		||||
@api_team.get("/id/<team_id>/players")
 | 
			
		||||
@spec.validate(
 | 
			
		||||
| 
						 | 
				
			
			@ -251,7 +253,7 @@ class ViewTeamMembersResponse(PlayerSchema):
 | 
			
		|||
)
 | 
			
		||||
@requires_authentication
 | 
			
		||||
def view_team_members(player: Player, team_id: int, **kwargs):
 | 
			
		||||
    now = datetime.datetime.now(datetime.timezone.utc)
 | 
			
		||||
    now = datetime.now(timezone.utc)
 | 
			
		||||
 | 
			
		||||
    player_teams_query = db.session.query(
 | 
			
		||||
        PlayerTeam
 | 
			
		||||
| 
						 | 
				
			
			@ -318,7 +320,6 @@ def edit_member_roles(
 | 
			
		|||
    target_player_id: int,
 | 
			
		||||
    **kwargs,
 | 
			
		||||
):
 | 
			
		||||
    print("hiiii lol")
 | 
			
		||||
    target_player = db.session.query(
 | 
			
		||||
        PlayerTeam
 | 
			
		||||
    ).where(
 | 
			
		||||
| 
						 | 
				
			
			@ -353,3 +354,166 @@ def edit_member_roles(
 | 
			
		|||
    db.session.commit()
 | 
			
		||||
 | 
			
		||||
    return make_response({ }, 204)
 | 
			
		||||
 | 
			
		||||
@api_team.get("/id/<team_id>/invite")
 | 
			
		||||
@spec.validate(
 | 
			
		||||
    resp=Response(
 | 
			
		||||
        HTTP_200=list[TeamInviteSchema],
 | 
			
		||||
        HTTP_404=None,
 | 
			
		||||
    ),
 | 
			
		||||
    operation_id="get_invites"
 | 
			
		||||
)
 | 
			
		||||
@requires_authentication
 | 
			
		||||
def get_invites(player: Player, team_id: int, **kwargs):
 | 
			
		||||
    player_team = db.session.query(
 | 
			
		||||
        PlayerTeam
 | 
			
		||||
    ).where(
 | 
			
		||||
        PlayerTeam.player_id == player.steam_id
 | 
			
		||||
    ).where(
 | 
			
		||||
        PlayerTeam.team_id == team_id
 | 
			
		||||
    ).one_or_none()
 | 
			
		||||
 | 
			
		||||
    if not player_team:
 | 
			
		||||
        abort(404)
 | 
			
		||||
 | 
			
		||||
    invites = db.session.query(
 | 
			
		||||
        TeamInvite
 | 
			
		||||
    ).where(
 | 
			
		||||
        TeamInvite.team_id == team_id
 | 
			
		||||
    ).all()
 | 
			
		||||
 | 
			
		||||
    def map_invite_to_schema(invite: TeamInvite):
 | 
			
		||||
        return TeamInviteSchema(
 | 
			
		||||
            key=invite.key,
 | 
			
		||||
            team_id=invite.team_id,
 | 
			
		||||
            created_at=invite.created_at,
 | 
			
		||||
        ).dict(by_alias=True)
 | 
			
		||||
 | 
			
		||||
    return list(map(map_invite_to_schema, invites)), 200
 | 
			
		||||
 | 
			
		||||
@api_team.post("/id/<team_id>/invite")
 | 
			
		||||
@spec.validate(
 | 
			
		||||
    resp=Response(
 | 
			
		||||
        HTTP_200=TeamInviteSchema,
 | 
			
		||||
        HTTP_404=None,
 | 
			
		||||
    ),
 | 
			
		||||
    operation_id="create_invite"
 | 
			
		||||
)
 | 
			
		||||
@requires_authentication
 | 
			
		||||
def create_invite(player: Player, team_id: int, **kwargs):
 | 
			
		||||
    player_team = db.session.query(
 | 
			
		||||
        PlayerTeam
 | 
			
		||||
    ).where(
 | 
			
		||||
        PlayerTeam.player_id == player.steam_id
 | 
			
		||||
    ).where(
 | 
			
		||||
        PlayerTeam.team_id == team_id
 | 
			
		||||
    ).one_or_none()
 | 
			
		||||
 | 
			
		||||
    if not player_team:
 | 
			
		||||
        abort(404)
 | 
			
		||||
 | 
			
		||||
    team_id_shifted = int(team_id) << 48
 | 
			
		||||
    random_value_shifted = int(randint(0, (1 << 16) - 1)) << 32
 | 
			
		||||
    timestamp = int(time.time()) & ((1 << 32) - 1)
 | 
			
		||||
 | 
			
		||||
    key_int = timestamp | team_id_shifted | random_value_shifted
 | 
			
		||||
    key_hex = "%0.16X" % key_int
 | 
			
		||||
 | 
			
		||||
    invite = TeamInvite()
 | 
			
		||||
    invite.team_id = team_id
 | 
			
		||||
    invite.key = key_hex
 | 
			
		||||
 | 
			
		||||
    db.session.add(invite)
 | 
			
		||||
    db.session.flush()
 | 
			
		||||
    db.session.refresh(invite)
 | 
			
		||||
 | 
			
		||||
    response = TeamInviteSchema(
 | 
			
		||||
        key=key_hex,
 | 
			
		||||
        team_id=team_id,
 | 
			
		||||
        created_at=invite.created_at
 | 
			
		||||
    )
 | 
			
		||||
 | 
			
		||||
    db.session.commit()
 | 
			
		||||
 | 
			
		||||
    return response.dict(by_alias=True), 200
 | 
			
		||||
 | 
			
		||||
@api_team.post("/id/<team_id>/consume-invite/<key>")
 | 
			
		||||
@spec.validate(
 | 
			
		||||
    resp=Response(
 | 
			
		||||
        HTTP_204=None,
 | 
			
		||||
        HTTP_404=None,
 | 
			
		||||
    ),
 | 
			
		||||
    operation_id="consume_invite"
 | 
			
		||||
)
 | 
			
		||||
@requires_authentication
 | 
			
		||||
def consume_invite(player: Player, team_id: int, key: str, **kwargs):
 | 
			
		||||
    invite = db.session.query(
 | 
			
		||||
        TeamInvite
 | 
			
		||||
    ).where(
 | 
			
		||||
        TeamInvite.team_id == team_id
 | 
			
		||||
    ).where(
 | 
			
		||||
        TeamInvite.key == key
 | 
			
		||||
    ).one_or_none()
 | 
			
		||||
 | 
			
		||||
    if not invite:
 | 
			
		||||
        abort(404)
 | 
			
		||||
 | 
			
		||||
    player_team = db.session.query(
 | 
			
		||||
        PlayerTeam
 | 
			
		||||
    ).where(
 | 
			
		||||
        PlayerTeam.player_id == player.steam_id
 | 
			
		||||
    ).where(
 | 
			
		||||
        PlayerTeam.team_id == team_id
 | 
			
		||||
    ).one_or_none()
 | 
			
		||||
 | 
			
		||||
    if player_team:
 | 
			
		||||
        abort(409)
 | 
			
		||||
 | 
			
		||||
    player_team = PlayerTeam()
 | 
			
		||||
    player_team.player = player
 | 
			
		||||
    player_team.team_id = team_id
 | 
			
		||||
 | 
			
		||||
    db.session.add(player_team)
 | 
			
		||||
 | 
			
		||||
    if invite.delete_on_use:
 | 
			
		||||
        db.session.delete(invite)
 | 
			
		||||
 | 
			
		||||
    db.session.commit()
 | 
			
		||||
 | 
			
		||||
    return make_response({ }, 204)
 | 
			
		||||
 | 
			
		||||
@api_team.delete("/id/<team_id>/invite/<key>")
 | 
			
		||||
@spec.validate(
 | 
			
		||||
    resp=Response(
 | 
			
		||||
        HTTP_204=None,
 | 
			
		||||
        HTTP_404=None,
 | 
			
		||||
    ),
 | 
			
		||||
    operation_id="revoke_invite"
 | 
			
		||||
)
 | 
			
		||||
@requires_authentication
 | 
			
		||||
def revoke_invite(player: Player, team_id: int, key: str, **kwargs):
 | 
			
		||||
    player_team = db.session.query(
 | 
			
		||||
        PlayerTeam
 | 
			
		||||
    ).where(
 | 
			
		||||
        PlayerTeam.player_id == player.steam_id
 | 
			
		||||
    ).where(
 | 
			
		||||
        PlayerTeam.team_id == team_id
 | 
			
		||||
    ).one_or_none()
 | 
			
		||||
 | 
			
		||||
    if not player_team:
 | 
			
		||||
        abort(404)
 | 
			
		||||
 | 
			
		||||
    invite = db.session.query(
 | 
			
		||||
        TeamInvite
 | 
			
		||||
    ).where(
 | 
			
		||||
        TeamInvite.team_id == team_id
 | 
			
		||||
    ).where(
 | 
			
		||||
        TeamInvite.key == key
 | 
			
		||||
    ).one_or_none()
 | 
			
		||||
 | 
			
		||||
    if not invite:
 | 
			
		||||
        abort(404)
 | 
			
		||||
 | 
			
		||||
    db.session.delete(invite)
 | 
			
		||||
    db.session.commit()
 | 
			
		||||
    return make_response({ }, 204)
 | 
			
		||||
| 
						 | 
				
			
			
 | 
			
		|||
		Loading…
	
		Reference in New Issue