diff --git a/api/src/routes/roles.ts b/api/src/routes/roles.ts index d0f0e68..d77093f 100644 --- a/api/src/routes/roles.ts +++ b/api/src/routes/roles.ts @@ -5,7 +5,8 @@ const ur = express.Router(); import { MemberState } from '@app/shared/types/member'; import pool from '../db'; import { requireLogin, requireMemberState, requireRole } from '../middleware/auth'; -import { assignUserGroup, createGroup } from '../services/rolesService'; +import { assignUserGroup, createGroup, getAllRoles, getRole, getUsersWithRole } from '../services/rolesService'; +import { Request, Response } from 'express'; r.use(requireLogin) ur.use(requireLogin) @@ -15,10 +16,16 @@ ur.post('/', [requireMemberState(MemberState.Member), requireRole("17th Administ try { const body = req.body; - assignUserGroup(body.member_id, body.role_id); + await assignUserGroup(body.member_id, body.role_id); res.sendStatus(201); } catch (err) { + if (err?.code === 'ER_DUP_ENTRY') { + return res.status(400).json({ + error: 'Member already has this role', + }); + } + console.error('Insert failed:', err); res.status(500).json({ error: 'Failed to add to group' }); } @@ -44,45 +51,39 @@ ur.delete('/', [requireMemberState(MemberState.Member), requireRole("17th Admini //get all roles r.get('/', [requireMemberState(MemberState.Member)], async (req, res) => { try { - var con = await pool.getConnection(); - - // Get all roles - const roles = await con.query('SELECT * FROM roles;'); - - // Get all members for each role - const membersRoles = await con.query(` - SELECT mr.role_id, v.* - FROM members_roles mr - JOIN view_member_rank_unit_status_latest v ON mr.member_id = v.member_id - `); - - - // Group members by role_id - const roleIdToMembers = {}; - for (const row of membersRoles) { - if (!roleIdToMembers[row.role_id]) roleIdToMembers[row.role_id] = []; - // Remove role_id from member object - const { role_id, ...member } = row; - roleIdToMembers[role_id].push(member); - } - - // Attach members to each role - const result = roles.map(role => ({ - ...role, - members: roleIdToMembers[role.id] || [] - })); - - res.json(result); + const roles = await getAllRoles(); + res.status(200).json(roles); } catch (err) { console.error(err); - res.status(500).json({ error: 'Internal server error' }); - } finally { - con.release(); + res.sendStatus(500); } }); +r.get('/:id/members', [requireMemberState(MemberState.Member)], async (req: Request, res: Response) => { + try { + const members = await getUsersWithRole(Number(req.params.id)); + res.status(200).json(members); + } catch (err) { + console.error(err); + res.sendStatus(500); + } +}) + + +r.get('/:id', [requireMemberState(MemberState.Member)], async (req: Request, res: Response) => { + try { + const role = await getRole(Number(req.params.id)); + res.status(200).json(role); + } catch (err) { + console.error(err); + res.sendStatus(500); + } +}) + + + //create a new role -r.post('/', [requireMemberState(MemberState.Member), requireRole("17th Administrator")], async (req, res) => { +r.post('/', [requireMemberState(MemberState.Member), requireRole("dev")], async (req, res) => { try { const { name, color, description } = req.body; if (!name || !color) { @@ -103,7 +104,7 @@ r.post('/', [requireMemberState(MemberState.Member), requireRole("17th Administr } }) -r.delete('/:id', [requireMemberState(MemberState.Member), requireRole("17th Administrator")], async (req, res) => { +r.delete('/:id', [requireMemberState(MemberState.Member), requireRole("dev")], async (req, res) => { try { const id = req.params.id; diff --git a/api/src/services/memberService.ts b/api/src/services/memberService.ts index 844ef33..e1f4fed 100644 --- a/api/src/services/memberService.ts +++ b/api/src/services/memberService.ts @@ -1,5 +1,6 @@ +import { Role } from "@app/shared/types/roles"; import pool from "../db"; -import { Member, MemberLight, memberSettings, MemberState } from '@app/shared/types/member' +import { Member, MemberCardDetails, MemberLight, memberSettings, MemberState } from '@app/shared/types/member' export async function getUserData(userID: number): Promise { const sql = `SELECT * FROM view_member_rank_unit_status_latest WHERE member_id = ?`; @@ -60,10 +61,50 @@ export async function getAllMembersLite(): Promise { return res; } -export async function getMembersFull(ids: number[]): Promise { - const sql = `SELECT * FROM view_member_rank_unit_status_latest WHERE member_id IN (?);`; - const res: Member[] = await pool.query(sql, [ids]); - return res; +export async function getMembersFull(ids: number[]): Promise { + const sql = ` + SELECT m.*, + COALESCE( + JSON_ARRAYAGG( + CASE + WHEN r.id IS NOT NULL THEN JSON_OBJECT( + 'id', r.id, + 'name', r.name, + 'color', r.color, + 'description', r.description + ) + END + ), + JSON_ARRAY() + ) AS roles + FROM view_member_rank_unit_status_latest m + LEFT JOIN members_roles mr ON m.member_id = mr.member_id + LEFT JOIN roles r ON mr.role_id = r.id + WHERE m.member_id IN (?) + GROUP BY m.member_id; + `; + + const rows: any[] = await pool.query(sql, [ids]); + + return rows.map(row => { + const member: Member = { + member_id: row.member_id, + member_name: row.member_name, + displayName: row.displayName, + rank: row.rank, + rank_date: row.rank_date, + unit: row.unit, + unit_date: row.unit_date, + status: row.status, + status_date: row.status_date, + loa_until: row.loa_until ? new Date(row.loa_until) : undefined, + }; + + // roles comes as array of strings; parse each one + const roles: Role[] = JSON.parse(row.roles).map((r: string) => JSON.parse(r)); + + return { member, roles }; + }); } export async function mapDiscordtoID(id: number): Promise { diff --git a/api/src/services/rolesService.ts b/api/src/services/rolesService.ts index f9e724c..19eb228 100644 --- a/api/src/services/rolesService.ts +++ b/api/src/services/rolesService.ts @@ -1,8 +1,8 @@ +import { MemberLight } from '@app/shared/types/member'; import pool from '../db'; -import { Role } from '@app/shared/types/roles' +import { Role, RoleSummary } from '@app/shared/types/roles' export async function assignUserGroup(userID: number, roleID: number) { - const sql = `INSERT INTO members_roles (member_id, role_id) VALUES (?, ?);`; const params = [userID, roleID]; @@ -24,4 +24,34 @@ export async function getUserRoles(userID: number): Promise { WHERE mr.member_id = ?;`; return await pool.query(sql, [userID]); +} + +export async function getRole(id: number): Promise { + let res = await pool.query(`SELECT * FROM roles WHERE id = ?`, [id]) + return res[0] as Role; +} + +export async function getAllRoles(): Promise { + return await pool.query(`SELECT id, name, color FROM roles`); +} + +export async function getUsersWithRole(roleId: number): Promise { + const out = await pool.query( + ` + SELECT + m.member_id AS id, + m.member_name AS username, + m.displayName, + u.color + FROM members_roles mr + JOIN view_member_rank_unit_status_latest m + ON m.member_id = mr.member_id + LEFT JOIN units u + ON u.name = m.unit + WHERE mr.role_id = ? + `, + [roleId] + ) + + return out as MemberLight[] } \ No newline at end of file diff --git a/shared/types/member.ts b/shared/types/member.ts index 7caa9f0..eea7f85 100644 --- a/shared/types/member.ts +++ b/shared/types/member.ts @@ -34,6 +34,11 @@ export interface MemberLight { color: string } +export interface MemberCardDetails { + member: Member; + roles: Role[]; +} + export interface myData { member: Member; LOAs: LOARequest[]; diff --git a/shared/types/roles.ts b/shared/types/roles.ts index a232c52..08ab762 100644 --- a/shared/types/roles.ts +++ b/shared/types/roles.ts @@ -1,6 +1,14 @@ +import { MemberLight } from "./member"; + export interface Role { id: number; name: string; color?: string; description?: string; +} + +export interface RoleSummary { + id: number; + name: string; + color?: string; } \ No newline at end of file diff --git a/ui/src/api/member.ts b/ui/src/api/member.ts index 327a5ce..b6aeb47 100644 --- a/ui/src/api/member.ts +++ b/ui/src/api/member.ts @@ -1,4 +1,4 @@ -import { memberSettings, Member, MemberLight } from "@shared/types/member"; +import { memberSettings, Member, MemberLight, MemberCardDetails } from "@shared/types/member"; // @ts-ignore const addr = import.meta.env.VITE_APIHOST; @@ -71,7 +71,7 @@ export async function getLightMembers(ids: number[]): Promise { return response.json(); } -export async function getFullMembers(ids: number[]): Promise { +export async function getFullMembers(ids: number[]): Promise { if (ids.length === 0) return []; diff --git a/ui/src/api/roles.ts b/ui/src/api/roles.ts index 3fb67ee..6ec36e8 100644 --- a/ui/src/api/roles.ts +++ b/ui/src/api/roles.ts @@ -1,10 +1,5 @@ -export type Role = { - id: number; - name: string; - color: string; - description: string | null; - members: any[]; -}; +import { Member, MemberLight } from "@shared/types/member"; +import { Role } from "@shared/types/roles"; // @ts-ignore const addr = import.meta.env.VITE_APIHOST; @@ -22,6 +17,30 @@ export async function getRoles(): Promise { } } +export async function getRoleDetails(id: number): Promise { + const res = await fetch(`${addr}/roles/${id}`, { + credentials: 'include', + }) + + if (res.ok) { + return res.json() as Promise; + } else { + throw new Error("Could not load role"); + } +} + +export async function getRoleMembers(id: number): Promise { + const res = await fetch(`${addr}/roles/${id}/members`, { + credentials: 'include', + }) + + if (res.ok) { + return res.json(); + } else { + throw new Error("Could not load members"); + } +} + export async function createRole(name: string, color: string, description: string | null): Promise { const res = await fetch(`${addr}/roles`, { method: "POST", diff --git a/ui/src/components/members/MemberCard.vue b/ui/src/components/members/MemberCard.vue index 6195998..7b4424c 100644 --- a/ui/src/components/members/MemberCard.vue +++ b/ui/src/components/members/MemberCard.vue @@ -1,7 +1,7 @@ + + \ No newline at end of file diff --git a/ui/src/components/roles/roleView.vue b/ui/src/components/roles/roleView.vue new file mode 100644 index 0000000..9d7437f --- /dev/null +++ b/ui/src/components/roles/roleView.vue @@ -0,0 +1,142 @@ + + + diff --git a/ui/src/components/ui/input-group/index.js b/ui/src/components/ui/input-group/index.js index f1081fd..ea6ba22 100644 --- a/ui/src/components/ui/input-group/index.js +++ b/ui/src/components/ui/input-group/index.js @@ -2,10 +2,10 @@ import { cva } from "class-variance-authority"; export { default as InputGroup } from "./InputGroup.vue"; export { default as InputGroupAddon } from "./InputGroupAddon.vue"; -export { default as InputGroupButton } from "./InputGroupButton.vue"; -export { default as InputGroupInput } from "./InputGroupInput.vue"; -export { default as InputGroupText } from "./InputGroupText.vue"; -export { default as InputGroupTextarea } from "./InputGroupTextarea.vue"; +// export { default as InputGroupButton } from "./InputGroupButton.vue"; +// export { default as InputGroupInput } from "./InputGroupInput.vue"; +// export { default as InputGroupText } from "./InputGroupText.vue"; +// export { default as InputGroupTextarea } from "./InputGroupTextarea.vue"; export const inputGroupAddonVariants = cva( "text-muted-foreground flex h-auto cursor-text items-center justify-center gap-2 py-1.5 text-sm font-medium select-none [&>svg:not([class*='size-'])]:size-4 [&>kbd]:rounded-[calc(var(--radius)-5px)] group-data-[disabled=true]/input-group:opacity-50", diff --git a/ui/src/components/ui/input/Input.vue b/ui/src/components/ui/input/Input.vue index dc16a12..64deba9 100644 --- a/ui/src/components/ui/input/Input.vue +++ b/ui/src/components/ui/input/Input.vue @@ -22,7 +22,7 @@ const modelValue = useVModel(props, "modelValue", emits, { data-slot="input" :class=" cn( - 'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm', + 'file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm', 'focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]', 'aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive', props.class, diff --git a/ui/src/pages/ManageRoles.vue b/ui/src/pages/ManageRoles.vue index cf437ff..492610a 100644 --- a/ui/src/pages/ManageRoles.vue +++ b/ui/src/pages/ManageRoles.vue @@ -9,7 +9,7 @@ import { CardTitle, } from '@/components/ui/card' import { onMounted, ref, computed, reactive, watch } from 'vue'; -import { addMemberToRole, createRole, deleteRole, getRoles, removeMemberFromRole, Role } from '@/api/roles'; +import { addMemberToRole, createRole, deleteRole, getRoles, removeMemberFromRole } from '@/api/roles'; import Badge from '@/components/ui/badge/Badge.vue'; import { Dialog, @@ -34,8 +34,11 @@ import { Plus, X } from 'lucide-vue-next'; import Separator from '@/components/ui/separator/Separator.vue'; import Input from '@/components/ui/input/Input.vue'; import Label from '@/components/ui/label/Label.vue'; -import { getMembers } from '@/api/member'; -import { Member } from '@shared/types/member'; +import { getAllLightMembers, getMembers } from '@/api/member'; +import { Member, MemberLight } from '@shared/types/member'; +import { Role } from '@shared/types/roles'; +import RoleView from '@/components/roles/roleView.vue'; +import { useRoute } from 'vue-router'; const roles = ref([]) const activeRole = ref(null) @@ -43,16 +46,9 @@ const showDialog = ref(false); const showCreateGroupDialog = ref(false); const addingMember = ref(false); const memberToAdd = ref(null); +const route = useRoute(); -const allMembers = ref([]) -const availableMembers = computed(() => { - if (!activeRole.value) return []; - return allMembers.value.filter( - member => !activeRole.value!.members.some( - roleMember => roleMember.member_id === member.member_id - ) - ); -}) +const allMembers = ref([]) type RoleDraft = { name: string @@ -117,141 +113,40 @@ async function handleCreateGroup() { } } -async function handleAddMember() { - //guard - if (memberToAdd.value == null) - return; - await addMemberToRole(memberToAdd.value.member_id, activeRole.value.id); - roles.value = await getRoles(); -} - -async function handleRemoveMember(memberId: number) { - removeMemberFromRole(memberId, activeRole.value.id); - roles.value = await getRoles(); -} - -async function handleDeleteRole() { - await deleteRole(activeRole.value.id); -} onMounted(async () => { roles.value = await getRoles(); - allMembers.value = await getMembers(); + allMembers.value = await getAllLightMembers(); })